Grafana Grafana

Manage Grafana with Terraform

Create folders, data sources, dashboards, alerts, teams, and service accounts from HCL.

15 min Intermediate

File provisioning covers data sources, dashboards, and alerting, but not users, teams, service accounts, or permissions. Terraform covers all of them, works against any running Grafana (self-hosted or Grafana Cloud) through its API, and keeps a state file so it can detect and correct drift. This guide sets up the provider and manages the common resources.

Prerequisites and provider

You need Terraform or OpenTofu, a reachable Grafana, and a service account token with a role that can manage what you declare (see the authentication guide for creating one). Keep the token out of the code:

terraform {
  required_providers {
    grafana = {
      source  = "grafana/grafana"
      version = ">= 2.9.0"   # pin to the version you have tested
    }
  }
}

variable "grafana_url" {
  type    = string
  default = "http://localhost:3000"
}

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

provider "grafana" {
  url  = var.grafana_url
  auth = var.grafana_token
}

Pass the token through the environment so it never lands in a file:

export TF_VAR_grafana_token='glsa_...'
terraform init

For Grafana Cloud, you can create the stack and its service account in the same configuration. The Grafana documentation shows a two-provider pattern: a cloud provider that authenticates with a Cloud access policy token to create the stack (grafana_cloud_stack) and a service account (grafana_cloud_stack_service_account with its token), and a second provider aliased to the stack that uses that token.

Folders and data sources

resource "grafana_folder" "apps" {
  title = "Applications"
}

resource "grafana_data_source" "prometheus" {
  type       = "prometheus"
  name       = "Prometheus"
  uid        = "prometheus"
  url        = "http://prometheus:9090"
  is_default = true

  json_data_encoded = jsonencode({
    timeInterval = "15s"
  })
}

resource "grafana_data_source" "postgres" {
  type = "postgres"
  name = "Postgres"
  uid  = "postgres-main"
  url  = "postgres.internal:5432"
  username = "grafana"

  json_data_encoded = jsonencode({
    database = "app"
    sslmode  = "require"
  })

  secure_json_data_encoded = jsonencode({
    password = var.postgres_password
  })
}

Setting uid explicitly keeps dashboards and alert rules portable across environments. Secrets passed through secure_json_data_encoded are stored encrypted by Grafana, but they are also written to Terraform state, so store the state in an encrypted, access-controlled backend.

Dashboards

resource "grafana_dashboard" "webapp" {
  folder      = grafana_folder.apps.uid
  config_json = file("${path.module}/dashboards/webapp.json")
  overwrite   = true
}

Use fileset to load a whole directory:

resource "grafana_dashboard" "all" {
  for_each    = fileset("${path.module}/dashboards", "*.json")
  folder      = grafana_folder.apps.uid
  config_json = file("${path.module}/dashboards/${each.value}")
}

The dashboard JSON is the same file you would use for file provisioning, so you can move between the two approaches. When you attach a dashboard to a folder, older provider versions referenced the folder’s id and newer ones prefer its uid; check the provider documentation for the version you pin.

Alerting

Terraform has resources for each part of alerting:

resource "grafana_contact_point" "slack" {
  name = "team-slack"

  slack {
    url       = var.slack_webhook_url
    recipient = "#alerts"
  }
}

resource "grafana_notification_policy" "default" {
  group_by      = ["alertname", "namespace"]
  contact_point = grafana_contact_point.slack.name

  group_wait      = "30s"
  group_interval  = "5m"
  repeat_interval = "4h"
}

Rule groups use grafana_rule_group with rule blocks whose data entries mirror the provisioning YAML (query, reduce, threshold). Mute timings and templates have grafana_mute_timing and grafana_message_template. Look up the exact attribute names in the provider documentation, since alerting resources have been revised across provider versions.

grafana_notification_policy manages the whole policy tree, as in file provisioning, so make sure only one tool owns it.

Teams, service accounts, and permissions

These are the resources files can’t express:

resource "grafana_team" "platform" {
  name  = "platform"
  email = "platform@example.com"
}

resource "grafana_service_account" "ci" {
  name = "ci-dashboards"
  role = "Editor"
}

resource "grafana_service_account_token" "ci" {
  name               = "ci-token"
  service_account_id = grafana_service_account.ci.id
}

resource "grafana_folder_permission" "apps" {
  folder_uid = grafana_folder.apps.uid

  permissions {
    team_id    = grafana_team.platform.id
    permission = "Edit"
  }
}

Treat the generated token as a secret output, and push it into your secret manager rather than printing it.

Workflow

terraform fmt -check
terraform validate
terraform plan -out tfplan
terraform apply tfplan

In CI, run plan on pull requests and apply on merge. The plan shows exactly which dashboards, data sources, and alerts will change.

Adopt what already exists

If someone built things in the UI, import them into state instead of recreating them:

terraform import grafana_folder.apps <folder-uid>
terraform import grafana_dashboard.webapp <dashboard-uid>
terraform import grafana_data_source.prometheus <data-source-uid>

Newer Terraform versions can also use import blocks. Import ID formats can differ between provider versions, so confirm them in the resource docs.

Terraform alongside file provisioning

Don’t manage the same resource with both. Pick a boundary and stick to it. A common split is file provisioning for data sources and dashboards (loaded at startup, no API token needed) and Terraform for teams, service accounts, and permissions. Another is Terraform for everything, with provisioning files limited to server settings.

Popular Guides