IaC Azure

Terraform Module: Azure Managed Grafana — a governed, SSO-ready observability dashboard in one block

Quick take — Provision Azure Managed Grafana with Terraform (azurerm ~> 4.0): system-assigned identity, Azure Monitor + Log Analytics data-source roles, Entra ID admin, and zone redundancy — wrapped in a reusable module. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "managed_grafana" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-grafana?ref=v1.0.0"

  name                = "..."  # Workspace name; globally unique in the `*.grafana.azure…
  resource_group_name = "..."  # Resource group for the workspace.
  location            = "..."  # Azure region (Managed Grafana is region-limited).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Managed Grafana is a fully managed Grafana service: Microsoft runs the Grafana servers, patches them, and hands you a *.grafana.azure.com endpoint backed by Entra ID single sign-on. You get the open-source Grafana experience for dashboards and alerting without owning the VMs, the TLS certificates, or the upgrade treadmill.

The catch is that a useful Managed Grafana instance is never just the workspace. To actually plot metrics you need a managed identity, and that identity needs Monitoring Reader on every subscription it queries (so the built-in Azure Monitor data source works) plus Reader on Log Analytics workspaces for KQL panels. You usually want one or more Entra ID Grafana Admin principals assigned at the data-plane, deterministic API key / service-account behaviour, and — for anything customer-facing — zone redundancy. Wiring all of that by hand, consistently, across dev/test/prod is exactly the kind of repetition a module exists to kill.

This module wraps azurerm_dashboard_grafana together with its identity, the role assignments that make its data sources work, and the Entra admin grants, so a team can stand up a compliant observability front-end with a handful of variables.

When to use it

Reach for something else if you only need a couple of static charts (an Azure Monitor Workbook is free and may suffice), or if you require Grafana plugins that the managed service does not allow on the Standard tier.

Module structure

terraform-module-azure-managed-grafana/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# ---------------------------------------------------------------------------
# Azure Managed Grafana workspace
# ---------------------------------------------------------------------------
resource "azurerm_dashboard_grafana" "this" {
  name                              = var.name
  resource_group_name               = var.resource_group_name
  location                          = var.location
  sku                               = var.sku
  grafana_major_version             = var.grafana_major_version
  zone_redundancy_enabled           = var.zone_redundancy_enabled
  api_key_enabled                   = var.api_key_enabled
  deterministic_outbound_ip_enabled = var.deterministic_outbound_ip_enabled
  public_network_access_enabled     = var.public_network_access_enabled

  identity {
    type = "SystemAssigned"
  }

  # Built-in Azure Monitor + managed Prometheus integration.
  azure_monitor_workspace_integrations {
    resource_id = var.azure_monitor_workspace_id
  }

  tags = var.tags

  lifecycle {
    # The data-plane stores dashboards/folders; never let an apply tear it down silently.
    prevent_destroy = false
  }
}

# ---------------------------------------------------------------------------
# Data-source RBAC: let Grafana's managed identity read Azure Monitor metrics
# across the requested scopes (subscriptions / resource groups).
# ---------------------------------------------------------------------------
resource "azurerm_role_assignment" "monitoring_reader" {
  for_each = toset(var.monitor_reader_scopes)

  scope                = each.value
  role_definition_name = "Monitoring Reader"
  principal_id         = azurerm_dashboard_grafana.this.identity[0].principal_id
}

# Reader on Log Analytics workspaces so KQL-backed panels resolve.
resource "azurerm_role_assignment" "log_analytics_reader" {
  for_each = toset(var.log_analytics_reader_scopes)

  scope                = each.value
  role_definition_name = "Reader"
  principal_id         = azurerm_dashboard_grafana.this.identity[0].principal_id
}

# ---------------------------------------------------------------------------
# Entra ID Grafana Admins (data-plane role on the workspace itself).
# "Grafana Admin" is a built-in role scoped to the Managed Grafana resource.
# ---------------------------------------------------------------------------
resource "azurerm_role_assignment" "grafana_admin" {
  for_each = toset(var.admin_object_ids)

  scope                = azurerm_dashboard_grafana.this.id
  role_definition_name = "Grafana Admin"
  principal_id         = each.value
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Managed Grafana workspace. Must be globally unique within the *.grafana.azure.com namespace."

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9-]{1,21}[a-zA-Z0-9]$", var.name))
    error_message = "name must be 3-23 chars, start with a letter, end alphanumeric, and contain only letters, digits, or hyphens."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group that will hold the Managed Grafana workspace."
}

variable "location" {
  type        = string
  description = "Azure region. Managed Grafana is only offered in a subset of regions (e.g. eastus, westeurope, australiaeast)."
}

variable "sku" {
  type        = string
  description = "Service tier. 'Standard' supports zone redundancy and higher limits; 'Essential' is the low-cost entry tier."
  default     = "Standard"

  validation {
    condition     = contains(["Essential", "Standard"], var.sku)
    error_message = "sku must be either 'Essential' or 'Standard'."
  }
}

variable "grafana_major_version" {
  type        = string
  description = "Grafana major version to run (e.g. \"10\" or \"11\"). Pin this so upgrades are an explicit, reviewed change."
  default     = "11"

  validation {
    condition     = contains(["10", "11"], var.grafana_major_version)
    error_message = "grafana_major_version must be a supported major version: \"10\" or \"11\"."
  }
}

variable "zone_redundancy_enabled" {
  type        = bool
  description = "Spread the workspace across availability zones. Requires the Standard SKU and must be set at create time (changing it forces replacement)."
  default     = false
}

variable "api_key_enabled" {
  type        = bool
  description = "Allow legacy Grafana API keys / service-account tokens to be minted against the workspace."
  default     = false
}

variable "deterministic_outbound_ip_enabled" {
  type        = bool
  description = "Pin the workspace to a stable set of outbound IPs so upstream data sources can allow-list them. Standard SKU only."
  default     = false
}

variable "public_network_access_enabled" {
  type        = bool
  description = "Whether the data plane is reachable from the public internet. Set false when fronting with a private endpoint."
  default     = true
}

variable "azure_monitor_workspace_id" {
  type        = string
  description = "Resource ID of an Azure Monitor workspace to wire in as a managed Prometheus data source. Use null to skip the integration."
  default     = null
}

variable "monitor_reader_scopes" {
  type        = list(string)
  description = "Scopes (subscription or resource-group IDs) where Grafana's identity is granted 'Monitoring Reader' for the Azure Monitor data source."
  default     = []
}

variable "log_analytics_reader_scopes" {
  type        = list(string)
  description = "Scopes where Grafana's identity is granted 'Reader' so Log Analytics (KQL) panels resolve."
  default     = []
}

variable "admin_object_ids" {
  type        = list(string)
  description = "Entra ID object IDs (users or groups) to grant the data-plane 'Grafana Admin' role on this workspace."
  default     = []
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to the Managed Grafana workspace."
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Managed Grafana workspace."
  value       = azurerm_dashboard_grafana.this.id
}

output "name" {
  description = "Name of the Managed Grafana workspace."
  value       = azurerm_dashboard_grafana.this.name
}

output "endpoint" {
  description = "Public HTTPS URL of the Grafana data plane."
  value       = azurerm_dashboard_grafana.this.endpoint
}

output "grafana_version" {
  description = "Full Grafana version running on the workspace."
  value       = azurerm_dashboard_grafana.this.grafana_version
}

output "identity_principal_id" {
  description = "Principal (object) ID of the workspace's system-assigned managed identity — use it for additional data-source role grants."
  value       = azurerm_dashboard_grafana.this.identity[0].principal_id
}

output "outbound_ips" {
  description = "Stable outbound IPs when deterministic_outbound_ip_enabled is true; empty otherwise."
  value       = azurerm_dashboard_grafana.this.outbound_ip
}

How to use it

data "azurerm_subscription" "current" {}

resource "azurerm_resource_group" "obs" {
  name     = "rg-observability-prod"
  location = "australiaeast"
}

resource "azurerm_monitor_workspace" "prom" {
  name                = "amw-prod-aue"
  resource_group_name = azurerm_resource_group.obs.name
  location            = azurerm_resource_group.obs.location
}

module "managed_grafana" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-grafana?ref=v1.0.0"

  name                = "graf-prod-aue"
  resource_group_name = azurerm_resource_group.obs.name
  location            = azurerm_resource_group.obs.location

  sku                               = "Standard"
  grafana_major_version             = "11"
  zone_redundancy_enabled           = true
  deterministic_outbound_ip_enabled = true
  api_key_enabled                   = false

  # Wire the managed Prometheus workspace in as a data source.
  azure_monitor_workspace_id = azurerm_monitor_workspace.prom.id

  # Let Grafana read metrics across the whole subscription, and KQL from this RG.
  monitor_reader_scopes       = [data.azurerm_subscription.current.id]
  log_analytics_reader_scopes = [azurerm_resource_group.obs.id]

  # Platform team's Entra group gets Grafana Admin on the data plane.
  admin_object_ids = ["11111111-2222-3333-4444-555555555555"]

  tags = {
    environment = "prod"
    owner       = "platform-observability"
  }
}

# Downstream reference: publish the dashboard URL as an APIM named value /
# portal link, and grant another component access using the identity output.
output "grafana_url" {
  value = module.managed_grafana.endpoint
}

resource "azurerm_role_assignment" "grafana_reads_aks_metrics" {
  scope                = azurerm_kubernetes_cluster.app.id
  role_definition_name = "Monitoring Reader"
  principal_id         = module.managed_grafana.identity_principal_id
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/managed_grafana/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-grafana?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/managed_grafana && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Workspace name; globally unique in the *.grafana.azure.com namespace (3–23 chars).
resource_group_name string Yes Resource group for the workspace.
location string Yes Azure region (Managed Grafana is region-limited).
sku string "Standard" No Essential or Standard. Zone redundancy and stable IPs need Standard.
grafana_major_version string "11" No Pinned Grafana major version (10 or 11).
zone_redundancy_enabled bool false No Multi-AZ spread; create-time only, Standard SKU.
api_key_enabled bool false No Allow legacy API keys / service-account tokens.
deterministic_outbound_ip_enabled bool false No Pin outbound IPs for upstream allow-listing (Standard SKU).
public_network_access_enabled bool true No Expose the data plane publicly; set false with private endpoints.
azure_monitor_workspace_id string null No Azure Monitor workspace ID to integrate as a managed Prometheus source.
monitor_reader_scopes list(string) [] No Scopes granted Monitoring Reader to the workspace identity.
log_analytics_reader_scopes list(string) [] No Scopes granted Reader for KQL/Log Analytics panels.
admin_object_ids list(string) [] No Entra object IDs granted the data-plane Grafana Admin role.
tags map(string) {} No Tags applied to the workspace.

Outputs

Name Description
id Resource ID of the Managed Grafana workspace.
name Workspace name.
endpoint Public HTTPS URL of the Grafana data plane.
grafana_version Full Grafana version running on the workspace.
identity_principal_id Principal ID of the system-assigned managed identity, for further data-source role grants.
outbound_ips Stable outbound IPs when deterministic IPs are enabled; empty otherwise.

Enterprise scenario

A retail platform team runs a dozen AKS clusters across three subscriptions, all emitting metrics to Azure Monitor managed Prometheus. They deploy this module once per region from their landing-zone pipeline: each Grafana workspace gets the managed Prometheus workspace wired in, Monitoring Reader granted at the subscription scope so any cluster’s metrics are queryable, and the on-call SRE Entra group assigned Grafana Admin. Engineers reach a single zone-redundant graf-prod-aue.grafana.azure.com via corporate SSO, with no shared passwords and no Grafana VMs to patch — and because access flows through the module’s admin_object_ids, off-boarding someone is a group-membership change, not a Terraform run.

Best practices

TerraformAzureManaged GrafanaModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading