Jeffrey Rengifo

One edit, every dashboard updated: managing Kibana observability at scale with Terraform

Define your golden-signals panels once in a shared HCL library and use for_each to generate every team's dashboard, with drift detection and git rollback built in.

Elastic ships a Kibana Dashboards API and a native Terraform resource for managing dashboards as code. This capability was introduced as a technical preview in Elastic 9.4 and was made generally available in Elastic 9.5. You define a golden signals panel library once in HCL, and for_each generates a dashboard for every team from it. When you need to change an error threshold, a panel layout or a query, one pull request updates every team at once. If something drifts or breaks, you roll back with git.

Why managing observability dashboards by hand breaks down at scale

Large organizations often end up with hundreds of dashboards. Teams build similar panels and maintain them using the Kibana UI.

When a small change comes in (a panel rename, a field fix, a new error threshold), there is no easy way to apply it across all of them. You either open each dashboard and edit it in the UI one by one, or you export the NDJSON, run a string replace, and re-import it.

Dashboards are code now

Elastic ships a typed Kibana Dashboards API and a native elasticstack_kibana_dashboard Terraform resource. You define a dashboard in an HCL file and then manage versions and changes as if it was regular code.

Golden signals dashboard: one definition for every team

The platform team owns a standard dashboard built on the four golden signals: latency, traffic, errors, and saturation. Every team should get that standard, and some teams add a panel or two of their own.

We want one definition of the standard, each team's dashboard generated from it, and a single change that reaches every team.

Prerequisites

  • An Elastic Cloud deployment or self-managed cluster running Elastic 9.4 or newer, or an Elastic Cloud Serverless project
  • Terraform installed
  • An Elasticsearch API key

The full Terraform configuration, the seed script, and the captured terraform plan outputs used in this article are available in the companion repo.

Configure the Elastic Terraform provider

Create a provider.tf next to the rest of your Terraform files:

terraform {
  required_providers {
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~> 0.11"
    }
  }
}

variable "elasticsearch_endpoint" {
  type = string
}

variable "elasticsearch_api_key" {
  type      = string
  sensitive = true
}

variable "kibana_endpoint" {
  type = string
}

variable "kibana_api_key" {
  type      = string
  sensitive = true
}

provider "elasticstack" {
  elasticsearch {
    endpoints = [var.elasticsearch_endpoint]
    api_key   = var.elasticsearch_api_key
  }
  kibana {
    endpoints = [var.kibana_endpoint]
    api_key   = var.kibana_api_key
  }
}

Provide your credentials through a local terraform.tfvars file (and add it to .gitignore so the keys never reach the repo):

elasticsearch_endpoint = "https://...es.region.cloud.es.io"
elasticsearch_api_key  = "..."
kibana_endpoint        = "https://...kb.region.cloud.es.io"
kibana_api_key         = "..."

You can use the same API key for both elasticsearch_api_key and kibana_api_key as long as it has dashboard write privileges in the target space.

Then initialize the working directory:

terraform init

Define a single-team Kibana dashboard in HCL

Start with a baseline dashboard for a single team. Panels sit on a 48-column grid, and each one is a Lens visualization configured inline. Use config_json for KPI tiles (it exposes secondary metrics and value coloring) and xy_chart_config for time-series charts.

Add the baseline resource to a new dashboards.tf:

resource "elasticstack_kibana_dashboard" "golden_signals" {
  title            = "Golden Signals - payments"
  description      = "Latency, traffic, errors"
  query            = { language = "kql", text = "" }
  refresh_interval = { pause = false, value = 60000 }
  time_range       = { from = "now-15m", to = "now" }

  panels = [
    {
      type = "vis"
      grid = { x = 0, y = 0, w = 12, h = 5 }
      config_json = jsonencode({
        type        = "metric"
        data_source = {
          type  = "esql"
          query = "FROM logs-payments-* | STATS `5xx errors` = COUNT(CASE(status >= 500, 1, null))"
        }
        metrics = [{ type = "primary", column = "5xx errors" }]
      })
    },
    # More panels follow the same shape: other metric tiles, xy_chart_config line charts, and a breakdown datatable. See the companion repo for the full file.
  ]
}

Each panel sets a type and grid position, then picks one chart kind. KPI tiles serialize the whole Lens config into config_json; the ES|QL query lives under data_source and the metric column is referenced by name in metrics[*].column. The dashboard time picker already scopes ES|QL panels, so the query needs no explicit @timestamp range filter.

Preview Kibana dashboard changes with terraform plan

Run terraform plan to see what Terraform will create:

terraform plan

The plan output lists the new elasticstack_kibana_dashboard.golden_signals resource and every attribute it will set: the top-level dashboard fields and one entry per panel with its grid position, chart kind, and data source.

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # elasticstack_kibana_dashboard.golden_signals will be created
  + resource "elasticstack_kibana_dashboard" "golden_signals" {
      + description      = "Latency, traffic, errors"
      + title            = "Golden Signals - payments"
      + query            = { language = "kql", text = "" }
      + refresh_interval = { pause = false, value = 60000 }
      + time_range       = { from = "now-15m", to = "now" }
      + panels           = [
          # Every panel described in full: KPI tiles (config_json),
          # line charts (xy_chart_config), and the breakdown datatable.
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Reviewing the plan is your last check before anything ships to Kibana.

Don't apply yet. The next section extends the file with per-team dashboards, and then a single terraform apply ships everything.

Generate per-team observability dashboards from a shared panel library

On top of the baseline, each team gets the standard set of panels, with the option to add a few of their own. Hardcoding one resource per team does not scale. Instead, define a panel library and a teams map as locals, then build the dashboards with for_each. Each library entry describes a chart kind, a title, and the data it needs; the resource emits the right Lens block (config_json for metric tiles, xy_chart_config for line charts) based on chart_type.

Replace the contents of dashboards.tf with:

locals {
  panel_library = {
    errors = {
      chart_type     = "metric"
      title          = "Error rate"
      esql_query_tpl = "FROM {idx} | STATS `5xx errors` = COUNT(CASE(status >= 500, 1, null))"
      esql_column    = "5xx errors"
    }
    saturation = {
      chart_type     = "metric"
      title          = "Saturation (CPU)"
      # Saturation reads from the metrics TSDB, so this query is not parameterized by {idx}.
      esql_query_tpl = "TS metrics-payments-* | STATS avg_cpu = AVG(cpu.pct)"
      esql_column    = "avg_cpu"
    }
    latency = {
      chart_type = "xy"
      title      = "Latency p95"
      x_json     = jsonencode({
        operation          = "date_histogram"
        field              = "@timestamp"
        suggested_interval = "auto"
      })
      y_json = jsonencode({
        operation  = "percentile"
        field      = "duration_ms"
        percentile = 95
      })
    }
    # ... more entries (traffic, cart_value) in the companion repo.
  }

  teams = {
    payments = {
      index  = "logs-payments-*"
      panels = ["errors", "saturation", "latency", "traffic"]
    }
    checkout = {
      index  = "logs-checkout-*"
      panels = ["errors", "cart_value", "latency", "traffic"]
    }
  }
}

resource "elasticstack_kibana_dashboard" "golden_signals" {
  for_each         = local.teams
  title            = "Golden Signals - ${each.key}"
  description      = "Latency, traffic, and errors for the ${each.key} service"
  query            = { language = "kql", text = "" }
  refresh_interval = { pause = false, value = 60000 }
  time_range       = { from = "now-15m", to = "now" }

  sections = [
    {
      title     = "KPIs"
      grid      = { y = 0 }
      collapsed = false
      panels = [
        for i, p in [for q in each.value.panels : q if local.panel_library[q].chart_type == "metric"] : {
          type        = "vis"
          grid        = { x = (i % 4) * 12, y = 0, w = 12, h = 5 }
          config_json = jsonencode({ ... }) # one metric tile per panel; see the companion repo for the full config
        }
      ]
    },
    {
      title     = "Trends"
      grid      = { y = 1 }
      collapsed = false
      panels = [
        for i, p in [for q in each.value.panels : q if local.panel_library[q].chart_type == "xy"] : {
          type       = "vis"
          grid       = { x = (i % 3) * 16, y = 0, w = 16, h = 10 }
          vis_config = { by_value = { xy_chart_config = { ... } } }
        }
      ]
    },
    # A third "Breakdown" section holds the request-by-status datatable. See the companion repo.
  ]
}

Adding a team is one entry in teams. Adding a panel to every team is one entry in panel_library and one reference per team. The full config (data source ES|QL queries, metrics, layers, axis defaults, and legend placement) lives in dashboards.tf.

The saturation panel queries the metrics data stream with the ES|QL TS command, which is designed for TSDB. For the query to work, data streams matching metrics-payments-* must use time_series mode, so the configuration also ships an index template (metrics_tsdb.tf) that enables that.

Apply dashboards as code to Kibana with terraform apply

Run terraform plan to confirm both team dashboards (payments and checkout) will be created then apply:

terraform apply

Open Kibana and you'll see one Golden Signals dashboard per team, each backed by its own index pattern.

Dashboards as code in the GitOps loop: review changes in pull requests

Dashboards are now an artifact in version control, like the rest of your infrastructure.

You edit the library or a team's selection, open a pull request, your reviewer reads the terraform plan diff and sees which dashboards change.

For example, say you tighten the "critical error" threshold from status >= 500 to status >= 503 in panel_library.errors.esql_query_tpl. Running terraform plan shows the change reaching both teams at once:

Note: Full output in terraform-plan-update.txt.

A single edit to panel_library.errors propagates to every team that references it. After the PR merges, it's time to run terraform apply.

After the apply finishes, refresh the dashboards in Kibana and the new threshold is in effect:

Detect dashboard drift and roll back with git

If someone edits a dashboard using the UI, the next terraform plan shows the difference, because the code and the live state no longer match.

To see this in action, open Golden Signals - payments in Kibana, rename the Latency p95 panel to Latency p95 (EDITED), and save the dashboard.

Then run terraform plan:

Terraform reads the panel title from the live dashboard, compares it against the code, and proposes reverting the UI rename. You decide whether to keep the change (update the code to match) or revert it by running terraform apply.

You can commit the new version, or rollback one or many versions using git.

Replaying the earlier example: if you reopen the PR that changed panel_library.errors to broaden the error threshold and add a clearer title, git diff dashboards.tf shows the entire intent in two lines:

Every team that references errors picks up the new threshold on the next terraform apply, and reverting that commit rolls the change back across all of them at once.

Wrap up

Managing Kibana observability dashboards by hand does not scale past a few teams. With the Kibana Dashboards API and Terraform, you define a standard once, compose each team's dashboard from a shared library, and review every change in a pull request. One edit reaches every team, and you can roll back by reverting a commit.

The proposed file structure is only one of many ways you can organize your dashboards depending on how much information they share.

Next steps

Share this article