The first time someone asked me “was this slower last month than it is now?”, I had no answer. My Prometheus only remembered two weeks. The data I needed had already aged out of local disk and been deleted. That gap is the whole reason this post exists.

Prometheus is the default for Kubernetes metrics, and for good reason. It works beautifully right up until you need long-term storage, or a view across multiple clusters, or genuine high availability. Then you meet the wall.

Thanos extends Prometheus instead of replacing it. You keep the setup you already understand, bolt on a few components, and get unlimited retention and global querying. That “keep what you know” part matters to me. I would rather grow a system I can already reason about than swap it for a black box that promises to do everything.

Where Standalone Prometheus Stops

A single Prometheus has four hard limits:

  1. Single node - No native clustering or HA
  2. Local storage - Retention is capped by disk size
  3. Single cluster view - You can’t query across clusters
  4. No downsampling - Old data eats as much space as new data

For one small cluster with two weeks of retention, none of this hurts. For a production multi-cluster setup with compliance requirements, every one of these is a blocker. The question is not whether you hit them, but when.

The Shape of Thanos

Thanos wraps a handful of components around your existing Prometheus:

flowchart TD
    subgraph clusterA["Cluster A"]
        PA["Prometheus + Sidecar"]
    end

    subgraph clusterB["Cluster B"]
        PB["Prometheus + Sidecar"]
    end

    PA --> OS["Object Storage<br/>(S3/MinIO/GCS)"]
    PB --> OS

    OS --> Q["Querier"]
    OS --> SG["Store Gateway"]
    OS --> C["Compactor"]

    SG --> Q
    Q --> G["Grafana"]

Sidecar - Runs next to Prometheus, uploads blocks to object storage Store Gateway - Serves historical data back from object storage Querier - Aggregates data from sidecars and the store gateway Compactor - Downsamples and deduplicates data in object storage

Four moving parts, and each one does a single job you can hold in your head. That’s the kind of design I trust.

The Simplest Version: Just Install It

Start with the Bitnami Helm chart and get something running:

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

helm install thanos bitnami/thanos \
  --namespace monitoring \
  --create-namespace \
  --set objstoreConfig="$(cat thanos-objstore.yaml)"

The object store config (thanos-objstore.yaml) points Thanos at your bucket:

type: s3
config:
  bucket: thanos-metrics
  endpoint: minio.storage:9000
  access_key: ${MINIO_ACCESS_KEY}
  secret_key: ${MINIO_SECRET_KEY}
  insecure: true  # For MinIO without TLS

That’s the whole skeleton. Everything after this is layering real-world detail on top.

Wiring Prometheus to the Sidecar

Next, tell Prometheus to run the Thanos sidecar alongside it:

apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
  name: prometheus
  namespace: monitoring
spec:
  replicas: 2  # HA pair
  retention: 2h  # Short local retention, Thanos handles long-term

  # Thanos sidecar configuration
  thanos:
    baseImage: quay.io/thanos/thanos
    version: v0.34.0
    objectStorageConfig:
      key: thanos.yaml
      name: thanos-objstore-secret

  # External labels for deduplication
  externalLabels:
    cluster: production
    replica: $(POD_NAME)

  # Let Thanos sidecar access Prometheus data
  storage:
    volumeClaimTemplate:
      spec:
        storageClassName: longhorn
        resources:
          requests:
            storage: 50Gi

The sidecar does three things:

  1. Exposes Prometheus data to the Thanos Querier over gRPC
  2. Uploads completed TSDB blocks to object storage
  3. Answers Store API queries for recent data

Notice the retention: 2h. Local disk now only holds the most recent slice. The long history lives in object storage, where it belongs.

Going Further: The Three Server Components

With the sidecar shipping blocks, you need the components that read them back.

Querier

apiVersion: apps/v1
kind: Deployment
metadata:
  name: thanos-querier
  namespace: monitoring
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: thanos-query
          image: quay.io/thanos/thanos:v0.34.0
          args:
            - query
            - --http-address=0.0.0.0:9090
            - --grpc-address=0.0.0.0:10901
            # Connect to sidecars
            - --store=dnssrv+_grpc._tcp.prometheus-operated.monitoring.svc
            # Connect to store gateway
            - --store=dnssrv+_grpc._tcp.thanos-store.monitoring.svc
            # Deduplication
            - --query.replica-label=replica
          ports:
            - name: http
              containerPort: 9090
            - name: grpc
              containerPort: 10901

Store Gateway

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: thanos-store
  namespace: monitoring
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: thanos-store
          image: quay.io/thanos/thanos:v0.34.0
          args:
            - store
            - --http-address=0.0.0.0:10902
            - --grpc-address=0.0.0.0:10901
            - --data-dir=/var/thanos/store
            - --objstore.config-file=/etc/thanos/objstore.yaml
          volumeMounts:
            - name: objstore-config
              mountPath: /etc/thanos
            - name: data
              mountPath: /var/thanos/store
      volumes:
        - name: objstore-config
          secret:
            secretName: thanos-objstore-secret
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        storageClassName: longhorn
        resources:
          requests:
            storage: 10Gi

Compactor

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: thanos-compactor
  namespace: monitoring
spec:
  replicas: 1  # Only one compactor!
  template:
    spec:
      containers:
        - name: thanos-compact
          image: quay.io/thanos/thanos:v0.34.0
          args:
            - compact
            - --http-address=0.0.0.0:10902
            - --data-dir=/var/thanos/compact
            - --objstore.config-file=/etc/thanos/objstore.yaml
            - --retention.resolution-raw=30d
            - --retention.resolution-5m=90d
            - --retention.resolution-1h=1y
            - --wait
          volumeMounts:
            - name: objstore-config
              mountPath: /etc/thanos
            - name: data
              mountPath: /var/thanos/compact

That replicas: 1 on the compactor is not a typo. Run two and they will fight over the same blocks and corrupt your data. I learned to put a big comment there so future-me does not get clever.

The retention tiers do the heavy lifting on cost:

  • Raw data: 30 days at full resolution
  • 5m downsampled: 90 days
  • 1h downsampled: 1 year

Older data takes less space because it has been downsampled. You trade resolution for history, which is exactly the trade you want for anything older than a couple of weeks.

GitOps Deployment

I do not click any of this together by hand. It goes through ArgoCD like everything else, so the cluster state lives in Git where I can read it, review it, and roll it back:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: thanos
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.bitnami.com/bitnami
    chart: thanos
    targetRevision: 12.20.0
    helm:
      values: |
        objstoreConfig: |-
          type: s3
          config:
            bucket: thanos-metrics
            endpoint: minio.storage:9000
            insecure: true

        query:
          enabled: true
          replicaCount: 2
          stores:
            - dnssrv+_grpc._tcp.prometheus-operated.monitoring.svc

        storegateway:
          enabled: true
          replicaCount: 2
          persistence:
            size: 20Gi

        compactor:
          enabled: true
          retentionResolutionRaw: 30d
          retentionResolution5m: 90d
          retentionResolution1h: 1y
          persistence:
            size: 50Gi

        ruler:
          enabled: false  # Use Prometheus rules instead

        receive:
          enabled: false  # Using sidecar mode

  destination:
    server: https://kubernetes.default.svc
    namespace: monitoring

Advanced Patterns: HA That Actually Survives a Node

Two Prometheus replicas scraping the same targets is only useful if something stitches their results back together. Thanos is that something.

# Run two Prometheus instances
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
spec:
  replicas: 2
  externalLabels:
    replica: $(POD_NAME)  # Different for each replica

Both instances scrape everything. The Querier deduplicates on the replica label so you don’t see doubled graphs:

# Querier configuration
args:
  - --query.replica-label=replica
  - --query.replica-label=prometheus_replica

Queries come back deduplicated automatically. Lose a node, lose a Prometheus pod, and your dashboards keep working from the surviving replica. That is resilience you can actually feel during an incident, instead of a checkbox in a runbook.

One Global View Across Clusters

Point multiple clusters at the same Thanos deployment and give each one a distinct cluster label.

Cluster A Prometheus:

externalLabels:
  cluster: production-eu
  replica: $(POD_NAME)

Cluster B Prometheus:

externalLabels:
  cluster: production-us
  replica: $(POD_NAME)

Now the Querier aggregates across both:

# Total requests across all clusters
sum(rate(http_requests_total[5m]))

# Requests by cluster
sum by (cluster) (rate(http_requests_total[5m]))

One PromQL query, every cluster, one answer. No more tab-juggling between three different Grafana instances to reconstruct what happened.

Grafana Integration

Grafana does not need to know Thanos exists. Point it at the Querier and it thinks it is talking to plain Prometheus:

apiVersion: v1
kind: ConfigMap
metadata:
  name: grafana-datasources
data:
  thanos.yaml: |
    apiVersion: 1
    datasources:
      - name: Thanos
        type: prometheus
        url: http://thanos-query.monitoring:9090
        access: proxy
        isDefault: true
        jsonData:
          timeInterval: "15s"

Every dashboard you already built keeps working. Point it at Thanos and move on.

Recording Rules for Performance

Some queries are expensive enough that you do not want to run them on every dashboard refresh. Pre-compute them once and read the result:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: recording-rules
spec:
  groups:
    - name: aggregations
      interval: 1m
      rules:
        # Pre-aggregate request rate by service
        - record: service:http_requests:rate5m
          expr: sum by (service) (rate(http_requests_total[5m]))

        # Pre-aggregate error rate
        - record: service:http_errors:rate5m
          expr: sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))

        # Pre-compute availability
        - record: service:availability:ratio
          expr: |
            1 - (
              service:http_errors:rate5m /
              service:http_requests:rate5m
            )

Dashboards then query the cheap service:* metrics instead of grinding through raw data on every load.

Alerting Architecture

Keep alerting close to the data. Run Alertmanager with Prometheus rather than with Thanos:

apiVersion: monitoring.coreos.com/v1
kind: Prometheus
spec:
  alerting:
    alertmanagers:
      - namespace: monitoring
        name: alertmanager
        port: web
  ruleSelector:
    matchLabels:
      role: alert-rules

Thanos Ruler exists and it works, but it adds another component to reason about and another failure mode to debug at 3am. For most setups Prometheus alerting is plenty, and fewer moving parts means fewer things that surprise you.

Monitoring Thanos Itself

Thanos exposes its own Prometheus metrics, which is convenient because the thing watching your systems is itself a system that can break. Watch these:

# Sidecar upload success
thanos_shipper_uploads_total
thanos_shipper_upload_failures_total

# Store gateway performance
thanos_bucket_store_series_fetch_duration_seconds
thanos_bucket_store_block_loads_total

# Compactor health
thanos_compact_group_compactions_total
thanos_compact_group_compaction_failures_total

# Querier performance
thanos_query_gate_duration_seconds

And alert when uploads start failing, because a silent sidecar means you are quietly losing history:

- alert: ThanosSidecarUploadFailing
  expr: increase(thanos_shipper_upload_failures_total[1h]) > 0
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "Thanos sidecar failing to upload blocks"

Storage Considerations

Here is where downsampling pays for itself:

ResolutionData per day1 year cost (S3)
Raw (15s)~100MB/target~$4/target
5m downsample~3MB/target~$0.12/target
1h downsample~0.5MB/target~$0.02/target

Keeping raw data forever is how you end up with a storage bill that quietly grows into a problem. Downsample aggressively and old data costs almost nothing.

For self-hosted object storage, MinIO does the job and keeps the data on hardware I own:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: minio
spec:
  template:
    spec:
      containers:
        - name: minio
          image: minio/minio:latest
          args:
            - server
            - /data
            - --console-address
            - ":9001"
          env:
            - name: MINIO_ROOT_USER
              valueFrom:
                secretKeyRef:
                  name: minio-credentials
                  key: root-user
            - name: MINIO_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: minio-credentials
                  key: root-password

The Full Picture: My Production Setup

Pulling every layer together, this is roughly what runs in production:

# Prometheus with sidecar
prometheus:
  replicas: 2
  retention: 6h  # Very short, Thanos handles long-term
  thanos:
    objectStorageConfig:
      name: thanos-objstore
  externalLabels:
    cluster: production
    environment: prod

# Thanos components
thanos:
  query:
    replicaCount: 2
    stores:
      - dnssrv+_grpc._tcp.prometheus-operated.monitoring.svc
      - dnssrv+_grpc._tcp.thanos-store.monitoring.svc

  storegateway:
    replicaCount: 2
    persistence:
      size: 50Gi

  compactor:
    retentionResolutionRaw: 14d
    retentionResolution5m: 60d
    retentionResolution1h: 365d
    persistence:
      size: 100Gi

# Object storage
minio:
  replicas: 4
  persistence:
    size: 500Gi

The decisions behind those numbers:

  • 6h local retention - The sidecar uploads often, so there is no reason to hoard data on local disk
  • 14d raw retention - Full resolution covers any recent debugging session
  • 1 year 1h retention - Enough trend history for capacity planning
  • Self-hosted MinIO - Data sovereignty, no cloud bill, no third party holding my metrics

Compare that to the three-line Helm install at the top. Same building blocks, just tuned for a system I have to live with.

Why This Matters

Metrics are how I understand my systems instead of guessing about them. They answer the questions that come up during every incident and every planning session:

  • Is this service healthy?
  • What changed right before the incident?
  • Are we meeting our SLOs?
  • Where is optimization actually worth the effort?

Lose long-term metrics and you lose the ability to answer “compared to when?”. Lose cross-cluster queries and you only ever see one piece of the picture at a time. Prometheus plus Thanos gives you unlimited retention, a global view, and high availability, all behind the same Prometheus interface you already know how to read.

That question from the start of this post, “was this slower last month?”, now has an answer I can pull up in a single query. Going from “what’s happening right now” to “what has been happening” is the difference between firefighting and actually steering the system.