Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for Amazon Managed Grafana: a service-managed workspace wired to Prometheus/CloudWatch/X-Ray, AWS SSO or SAML auth, role associations, optional SAML config, an IAM read role, and modern service-account tokens replacing deprecated API keys. 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 "aws" {
region = "us-east-1"
}
module "grafana" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"
name = "..." # Workspace name (e.g. "prod-observability").
# ... auth defaults to AWS_SSO; data sources default to Prometheus + CloudWatch.
}
Then terraform init && terraform apply. The module creates the IAM role Grafana assumes to read AMP/CloudWatch automatically — see Inputs below to add SAML, role associations, and service-account tokens.
What this module is
Amazon Managed Grafana (AMG) is a fully managed Grafana service: AWS runs, scales, patches, and secures the Grafana servers while you build dashboards and alerts. The central resource is a workspace (aws_grafana_workspace), which declares how users authenticate (AWS_SSO, SAML, or both), how permissions are managed (SERVICE_MANAGED lets AWS generate the IAM plumbing), which data sources the workspace can reach (Prometheus, CloudWatch, X-Ray, and more), and the IAM role Grafana assumes to actually query those sources. Around the workspace, aws_grafana_role_association maps SSO users/groups to Grafana’s ADMIN/EDITOR/VIEWER roles, aws_grafana_workspace_saml_configuration configures SAML when you bring your own IdP, and the modern aws_grafana_workspace_service_account + aws_grafana_workspace_service_account_token pair issues automation credentials — the supported replacement for the now-deprecated workspace API key.
The fiddly parts this module absorbs: with SERVICE_MANAGED permissions, Grafana still needs an IAM role whose trust policy allows the grafana.amazonaws.com service and whose permissions let it read whatever you listed in data_sources — get the policy wrong and dashboards silently return “no data.” Role associations require you to know SSO user/group IDs, and mixing AWS_SSO with SAML has its own rules. Service-account tokens are short-lived by design (max 30 days) and must be regenerated. This module creates the workspace with safe defaults (current-account access, service-managed permissions, Prometheus + CloudWatch data sources), builds the correctly-trusted IAM read role with a least-privilege policy scoped to the chosen data sources, wires role associations from a simple map, gates SAML behind a flag, and optionally mints service accounts with tokens — exposing the endpoint and role ARN as outputs.
When to use it
- You want a managed dashboards plane for your metrics and traces without running Grafana yourself, querying Amazon Managed Prometheus, CloudWatch, and X-Ray out of the box.
- You authenticate users through AWS IAM Identity Center (SSO) or a corporate SAML IdP, and want Grafana role assignments (admin/editor/viewer) managed as code per group.
- You need automation against the Grafana HTTP API — provisioning dashboards or data sources via CI — using service-account tokens rather than the deprecated, long-lived API key.
- You require a least-privilege IAM read role for Grafana that is scoped to exactly the data sources you enabled, applied identically across every environment.
Pair AMG with the Amazon Managed Prometheus module (AMG is its dashboard front end) and CloudWatch for AWS-native metrics and logs. Reach for self-hosted Grafana instead only when you need plugins or features AMG doesn’t support, or full control over the Grafana version and backend database.
Module structure
terraform-module-aws-managed-grafana/
├── versions.tf # provider + Terraform version pins
├── main.tf # workspace, IAM read role, role associations, SAML, service accounts
├── variables.tf # var-driven inputs with validations
└── outputs.tf # workspace id, endpoint, role ARN, service-account token (sensitive)
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
tags = merge(
{
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-managed-grafana"
},
var.tags,
)
# Map the requested data sources to the IAM actions Grafana needs to read them.
ds_policy_statements = concat(
contains(var.data_sources, "PROMETHEUS") ? [{
Effect = "Allow"
Action = [
"aps:ListWorkspaces",
"aps:DescribeWorkspace",
"aps:QueryMetrics",
"aps:GetLabels",
"aps:GetSeries",
"aps:GetMetricMetadata",
]
Resource = "*"
}] : [],
contains(var.data_sources, "CLOUDWATCH") ? [{
Effect = "Allow"
Action = [
"cloudwatch:DescribeAlarmsForMetric",
"cloudwatch:DescribeAlarmHistory",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListMetrics",
"cloudwatch:GetMetricData",
"cloudwatch:GetInsightRuleReport",
"logs:DescribeLogGroups",
"logs:GetLogGroupFields",
"logs:StartQuery",
"logs:StopQuery",
"logs:GetQueryResults",
"logs:GetLogEvents",
"ec2:DescribeTags",
"ec2:DescribeInstances",
"ec2:DescribeRegions",
"tag:GetResources",
]
Resource = "*"
}] : [],
contains(var.data_sources, "XRAY") ? [{
Effect = "Allow"
Action = [
"xray:BatchGetTraces",
"xray:GetTraceSummaries",
"xray:GetTraceGraph",
"xray:GetGroups",
"xray:GetTimeSeriesServiceStatistics",
"xray:GetInsightSummaries",
"xray:GetServiceGraph",
]
Resource = "*"
}] : [],
)
}
# IAM role Grafana assumes to read the configured data sources. Required even
# with SERVICE_MANAGED permissions so dashboards can actually query AMP/CW/X-Ray.
resource "aws_iam_role" "grafana" {
name = "${var.name}-grafana"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "grafana.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
tags = local.tags
}
resource "aws_iam_role_policy" "grafana" {
name = "${var.name}-grafana-datasources"
role = aws_iam_role.grafana.id
policy = jsonencode({
Version = "2012-10-17"
Statement = local.ds_policy_statements
})
}
resource "aws_grafana_workspace" "this" {
name = var.name
account_access_type = var.account_access_type
authentication_providers = var.authentication_providers
permission_type = var.permission_type
role_arn = aws_iam_role.grafana.arn
data_sources = var.data_sources
notification_destinations = var.notification_destinations
grafana_version = var.grafana_version
description = var.description
# Lock the workspace behind a VPC when supplied (subnets in >=2 AZs).
dynamic "vpc_configuration" {
for_each = var.vpc_configuration == null ? [] : [var.vpc_configuration]
content {
security_group_ids = vpc_configuration.value.security_group_ids
subnet_ids = vpc_configuration.value.subnet_ids
}
}
tags = local.tags
}
# Optional SAML configuration when authentication_providers includes "SAML".
resource "aws_grafana_workspace_saml_configuration" "this" {
count = var.saml_configuration == null ? 0 : 1
workspace_id = aws_grafana_workspace.this.id
editor_role_values = var.saml_configuration.editor_role_values
admin_role_values = var.saml_configuration.admin_role_values
idp_metadata_url = var.saml_configuration.idp_metadata_url
idp_metadata_xml = var.saml_configuration.idp_metadata_xml
role_assertion = var.saml_configuration.role_assertion
email_assertion = var.saml_configuration.email_assertion
login_assertion = var.saml_configuration.login_assertion
}
# Map SSO users/groups to Grafana roles (ADMIN/EDITOR/VIEWER).
resource "aws_grafana_role_association" "this" {
for_each = var.role_associations
workspace_id = aws_grafana_workspace.this.id
role = each.value.role
user_ids = each.value.user_ids
group_ids = each.value.group_ids
}
# Modern replacement for the deprecated workspace API key: a service account...
resource "aws_grafana_workspace_service_account" "this" {
for_each = var.service_accounts
name = each.key
grafana_role = each.value.grafana_role
workspace_id = aws_grafana_workspace.this.id
}
# ...and a short-lived token for it (max 30 days), used for HTTP API automation.
resource "aws_grafana_workspace_service_account_token" "this" {
for_each = var.service_accounts
name = "${each.key}-token"
service_account_id = aws_grafana_workspace_service_account.this[each.key].service_account_id
seconds_to_live = each.value.seconds_to_live
workspace_id = aws_grafana_workspace.this.id
}
variables.tf
variable "name" {
description = "Grafana workspace name."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-._~]{1,255}$", var.name))
error_message = "name must be 1-255 chars of letters, digits, hyphen, period, underscore, or tilde."
}
}
variable "account_access_type" {
description = "Account access scope: CURRENT_ACCOUNT or ORGANIZATION."
type = string
default = "CURRENT_ACCOUNT"
validation {
condition = contains(["CURRENT_ACCOUNT", "ORGANIZATION"], var.account_access_type)
error_message = "account_access_type must be CURRENT_ACCOUNT or ORGANIZATION."
}
}
variable "authentication_providers" {
description = "Authentication providers: any of AWS_SSO, SAML."
type = list(string)
default = ["AWS_SSO"]
validation {
condition = length(var.authentication_providers) > 0 && alltrue([
for p in var.authentication_providers : contains(["AWS_SSO", "SAML"], p)
])
error_message = "authentication_providers must be a non-empty subset of [AWS_SSO, SAML]."
}
}
variable "permission_type" {
description = "SERVICE_MANAGED (AWS manages IAM) or CUSTOMER_MANAGED."
type = string
default = "SERVICE_MANAGED"
validation {
condition = contains(["SERVICE_MANAGED", "CUSTOMER_MANAGED"], var.permission_type)
error_message = "permission_type must be SERVICE_MANAGED or CUSTOMER_MANAGED."
}
}
variable "data_sources" {
description = "Data sources Grafana can query (drives the IAM read policy)."
type = list(string)
default = ["PROMETHEUS", "CLOUDWATCH"]
validation {
condition = alltrue([
for d in var.data_sources : contains(
["AMAZON_OPENSEARCH_SERVICE", "ATHENA", "CLOUDWATCH", "PROMETHEUS",
"REDSHIFT", "SITEWISE", "TIMESTREAM", "TWINMAKER", "XRAY"],
d
)
])
error_message = "data_sources entries must be valid AMG data source names (e.g. PROMETHEUS, CLOUDWATCH, XRAY)."
}
}
variable "notification_destinations" {
description = "Notification destinations for Grafana alerting (only SNS is supported)."
type = list(string)
default = []
validation {
condition = alltrue([for n in var.notification_destinations : n == "SNS"])
error_message = "notification_destinations only supports SNS."
}
}
variable "grafana_version" {
description = "Grafana version to run (e.g. '9.4', '10.4', '12.4'). Null = latest."
type = string
default = null
}
variable "description" {
description = "Workspace description."
type = string
default = "Managed Grafana workspace provisioned by Terraform."
}
variable "vpc_configuration" {
description = "Optional VPC config so Grafana reaches in-VPC data sources. Null = public."
type = object({
security_group_ids = list(string)
subnet_ids = list(string)
})
default = null
validation {
condition = var.vpc_configuration == null || length(var.vpc_configuration.subnet_ids) >= 2
error_message = "vpc_configuration.subnet_ids must include subnets in at least two availability zones."
}
}
variable "saml_configuration" {
description = <<-EOT
Optional SAML configuration (requires "SAML" in authentication_providers). Provide
exactly one of idp_metadata_url / idp_metadata_xml.
editor_role_values - SAML assertion values mapped to the Editor role (required)
admin_role_values - SAML assertion values mapped to the Admin role (optional)
idp_metadata_url - URL of the IdP metadata (optional)
idp_metadata_xml - inline IdP metadata XML (optional)
role_assertion - assertion attribute carrying the role (optional)
email_assertion - assertion attribute carrying the email (optional)
login_assertion - assertion attribute carrying the login (optional)
EOT
type = object({
editor_role_values = list(string)
admin_role_values = optional(list(string))
idp_metadata_url = optional(string)
idp_metadata_xml = optional(string)
role_assertion = optional(string)
email_assertion = optional(string)
login_assertion = optional(string)
})
default = null
}
variable "role_associations" {
description = <<-EOT
Map keyed by an arbitrary label assigning SSO users/groups to a Grafana role.
role - ADMIN, EDITOR, or VIEWER (required)
user_ids - SSO user IDs (optional)
group_ids - SSO group IDs (optional)
EOT
type = map(object({
role = string
user_ids = optional(list(string))
group_ids = optional(list(string))
}))
default = {}
validation {
condition = alltrue([
for r in values(var.role_associations) : contains(["ADMIN", "EDITOR", "VIEWER"], r.role)
])
error_message = "Each role_associations role must be ADMIN, EDITOR, or VIEWER."
}
}
variable "service_accounts" {
description = <<-EOT
Map of service account name => settings. Each creates a service account and a
short-lived token (the modern replacement for the deprecated workspace API key).
grafana_role - ADMIN, EDITOR, or VIEWER (required)
seconds_to_live - token lifetime in seconds, max 30 days (default 2592000)
EOT
type = map(object({
grafana_role = string
seconds_to_live = optional(number, 2592000)
}))
default = {}
validation {
condition = alltrue([
for s in values(var.service_accounts) :
contains(["ADMIN", "EDITOR", "VIEWER"], s.grafana_role) &&
s.seconds_to_live > 0 && s.seconds_to_live <= 2592000
])
error_message = "service_accounts: grafana_role must be ADMIN/EDITOR/VIEWER and seconds_to_live in 1..2592000 (30 days)."
}
}
variable "tags" {
description = "Additional tags merged onto the workspace and IAM role."
type = map(string)
default = {}
}
outputs.tf
output "workspace_id" {
description = "Identifier of the Grafana workspace (g-xxxx)."
value = aws_grafana_workspace.this.id
}
output "workspace_arn" {
description = "ARN of the Grafana workspace."
value = aws_grafana_workspace.this.arn
}
output "endpoint" {
description = "Grafana workspace endpoint (the URL users and the HTTP API hit)."
value = aws_grafana_workspace.this.endpoint
}
output "grafana_version" {
description = "Grafana version running on the workspace."
value = aws_grafana_workspace.this.grafana_version
}
output "role_arn" {
description = "ARN of the IAM role Grafana assumes to read AMP/CloudWatch/X-Ray."
value = aws_iam_role.grafana.arn
}
output "service_account_ids" {
description = "Map of service account name => service account ID."
value = { for k, sa in aws_grafana_workspace_service_account.this : k => sa.service_account_id }
}
output "service_account_tokens" {
description = "Map of service account name => token key for HTTP API auth. Sensitive."
value = { for k, t in aws_grafana_workspace_service_account_token.this : k => t.key }
sensitive = true
}
How to use it
module "grafana" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"
name = "prod-observability"
account_access_type = "CURRENT_ACCOUNT"
authentication_providers = ["AWS_SSO"]
permission_type = "SERVICE_MANAGED"
# Drives the least-privilege IAM read role the module builds.
data_sources = ["PROMETHEUS", "CLOUDWATCH", "XRAY"]
notification_destinations = ["SNS"]
grafana_version = "10.4"
# Map SSO groups to Grafana roles.
role_associations = {
platform_admins = {
role = "ADMIN"
group_ids = ["ssogroup-1111-admins"]
}
app_editors = {
role = "EDITOR"
group_ids = ["ssogroup-2222-editors"]
}
everyone_view = {
role = "VIEWER"
group_ids = ["ssogroup-3333-alleng"]
}
}
# Service account + token for CI to push dashboards via the HTTP API.
service_accounts = {
ci-provisioner = {
grafana_role = "EDITOR"
seconds_to_live = 604800 # 7 days
}
}
tags = {
Environment = "prod"
Team = "observability"
CostCenter = "OBS-3300"
}
}
# Downstream: stash the service-account token in Secrets Manager for CI to read,
# instead of printing it or committing it. The value is marked sensitive.
resource "aws_secretsmanager_secret" "grafana_ci_token" {
name = "grafana/prod/ci-provisioner-token"
}
resource "aws_secretsmanager_secret_version" "grafana_ci_token" {
secret_id = aws_secretsmanager_secret.grafana_ci_token.id
secret_string = module.grafana.service_account_tokens["ci-provisioner"]
}
output "grafana_url" {
value = module.grafana.endpoint
}
For a SAML IdP instead of AWS SSO, set authentication_providers = ["SAML"] and supply saml_configuration with your IdP metadata URL and role-value mappings.
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 = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/grafana/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-managed-grafana?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/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 | Grafana workspace name. |
| account_access_type | string | CURRENT_ACCOUNT | No | CURRENT_ACCOUNT or ORGANIZATION. |
| authentication_providers | list(string) | [“AWS_SSO”] | No | Subset of [AWS_SSO, SAML]. |
| permission_type | string | SERVICE_MANAGED | No | SERVICE_MANAGED or CUSTOMER_MANAGED. |
| data_sources | list(string) | [“PROMETHEUS”,“CLOUDWATCH”] | No | Data sources to enable; drives the IAM read policy. |
| notification_destinations | list(string) | [] | No | Alerting destinations (only SNS supported). |
| grafana_version | string | null | No | Grafana version (e.g. 9.4, 10.4, 12.4); null = latest. |
| description | string | “Managed Grafana workspace…” | No | Workspace description. |
| vpc_configuration | object | null | No | Optional VPC config (security groups + subnets in ≥2 AZs). |
| saml_configuration | object | null | No | Optional SAML config (role values, IdP metadata, assertions). |
| role_associations | map(object) | {} | No | SSO user/group → Grafana role (ADMIN/EDITOR/VIEWER) mappings. |
| service_accounts | map(object) | {} | No | Service accounts + tokens for HTTP API automation. |
| tags | map(string) | {} | No | Additional tags merged onto the workspace and IAM role. |
Outputs
| Name | Description |
|---|---|
| workspace_id | Identifier of the Grafana workspace (g-xxxx). |
| workspace_arn | ARN of the Grafana workspace. |
| endpoint | Workspace endpoint URL (users and HTTP API). |
| grafana_version | Grafana version running on the workspace. |
| role_arn | IAM role ARN Grafana assumes to read AMP/CloudWatch/X-Ray. |
| service_account_ids | Map of service account name → service account ID. |
| service_account_tokens | Map of service account name → token key (sensitive). |
Enterprise scenario
An observability team gives the whole engineering org a single pane of glass over metrics and traces. They deploy this module at v1.0.0 per environment: each workspace authenticates through AWS IAM Identity Center, exposes Prometheus (the AMP workspace from the companion module), CloudWatch, and X-Ray as data sources, and is backed by a least-privilege IAM role the module generates from the data_sources list — so dashboards never silently fail with “access denied,” and the role grants nothing beyond read access to the enabled sources. SSO groups map to Grafana roles via role_associations (platform admins get ADMIN, app teams get EDITOR, everyone else VIEWER), so access tracks directory group membership with no manual user management. A CI pipeline provisions golden dashboards through the Grafana HTTP API using a 7-day service-account token — the supported successor to the deprecated workspace API key — read from Secrets Manager at runtime. The result: dashboards, data-source IAM, and access control are all code, reviewed in PRs and identical across dev, staging, and prod.
Best practices
- Let the module build the IAM read role from
data_sources. Even withSERVICE_MANAGEDpermissions, Grafana needs a role to query AMP/CloudWatch/X-Ray; this module scopes the policy to exactly the sources you enable, so you avoid both over-broad grants and the “no data” failures of an under-scoped role. - Use service-account tokens, never the deprecated API key.
aws_grafana_workspace_service_account+aws_grafana_workspace_service_account_tokenis the supported path for HTTP API automation; tokens max out at 30 days, so keep them short-lived and rotate by re-applying. - Treat tokens as secrets. The
service_account_tokensoutput is markedsensitive; write it straight into Secrets Manager or SSM SecureString for CI to read — never print it in logs or commit it. - Map roles to SSO groups, not individuals. Drive
role_associationsfrom directorygroup_idsso access follows job function and survives team changes; reserveADMINfor the platform team and default everyone else toVIEWER/EDITOR. - Pin
grafana_versiondeliberately. Leaving it null follows “latest” and can move dashboards/alerting under you; pin a supported version (e.g.10.4) and treat upgrades as a reviewed change, especially across the Grafana 9→10 unified-alerting boundary. - Lock the workspace to a VPC for private data sources, and tag for cost. Supply
vpc_configuration(subnets in ≥2 AZs) when Grafana must reach in-VPC Prometheus or databases; carryEnvironment,Team, andCostCentertags since AMG bills per active editor/admin user and you’ll want the spend attributable.