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
- You want a central dashboarding plane for Azure Monitor / Log Analytics / Managed Prometheus that engineers reach via corporate SSO, not a shared local password.
- You are running Azure Monitor managed service for Prometheus (e.g. from an AKS cluster) and need Grafana as the query front-end.
- You need observability provisioned as code alongside the workloads it watches, so every environment gets the same panels and the same access model.
- You want to avoid self-hosting Grafana on a VM or container and inheriting its patching, scaling, and certificate burden.
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 config — live/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 config — live/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
- Lean on the managed identity, never API keys. Keep
api_key_enabled = falseand grant the workspace’sidentity_principal_idthe least role each source needs —Monitoring Readerfor Azure Monitor,Readerfor Log Analytics — scoped as tightly as the dashboards require rather than at tenant root. - Manage access through Entra groups. Pass group object IDs in
admin_object_idsinstead of individual users so joiner/mover/leaver is handled in your IdP, and reserveGrafana Adminfor the platform team while most users get viewer/editor through Grafana’s own role mapping. - Decide zone redundancy and stable IPs up front.
zone_redundancy_enabledanddeterministic_outbound_ip_enabledrequire the Standard SKU and are set at create time — flipping them later forces a replacement that wipes data-plane state, so enable them on day one for production. - Pin
grafana_major_version. Treat Grafana upgrades as a reviewed, deliberate bump in the module call rather than drifting silently; this keeps dashboard JSON and plugin compatibility predictable across environments. - Cost-tune the SKU per environment. Use
Essentialfor ephemeral dev/test workspaces and reserveStandardfor prod where SLA, zone redundancy, and higher API limits actually matter; tag every workspace withenvironmentandownerfor showback. - Lock down the data plane when it fronts internal telemetry. Set
public_network_access_enabled = falseand reach Grafana over a private endpoint so the dashboards (and the metrics behind them) never traverse the public internet.