Quick take — A reusable Terraform module for google_monitoring_dashboard on hashicorp/google ~> 5.0: define Cloud Monitoring dashboards as JSON-driven, mosaic-layout code with labels, filters, and per-team ownership. 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 "google" {
project = "my-project"
region = "us-central1"
}
module "monitoring_dashboard" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-monitoring-dashboard?ref=v1.0.0"
project_id = "..." # GCP project ID where the dashboard is created. Validate…
display_name = "..." # Dashboard name shown in the console (1–75 chars). Force…
team = "..." # Owning team; applied as the `team` label. Validated aga…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
google_monitoring_dashboard is the Terraform resource that creates a Cloud Monitoring dashboard in a GCP project — the custom view in the Cloud Console (Monitoring → Dashboards) where you pin charts, scorecards, and alert summaries against your metrics. Unlike most GCP resources, a dashboard is configured almost entirely through a single, large dashboard_json payload that mirrors the Dashboard REST API schema: a displayName, a layout (mosaicLayout, gridLayout, rowLayout, or columnLayout), and an array of widgets, each containing time-series queries written in MQL, PromQL, or filter form.
Clicking dashboards together in the console is fine for a one-off, but it does not scale: the JSON drifts, nobody can review a chart change, and you cannot reproduce the “golden” SLO dashboard across 40 projects. Wrapping the resource in a module fixes that. The module takes the dashboard JSON as a string input (so you keep authoring widgets in the format GCP already documents), injects the correct project, enforces a naming and labeling convention for ownership and cost attribution, and optionally templatizes the JSON with templatefile() so the same dashboard definition can be stamped out per environment, per region, or per service. The result is observability that lives in the same PR as the service it watches.
When to use it
- You want dashboards-as-code: reviewed in PRs, versioned, and rolled back like any other infrastructure.
- You operate many projects or services and need the same SLO / golden-signals dashboard replicated consistently (one source of truth, many instances).
- You are building a platform/landing-zone offering and want to ship a default dashboard alongside every onboarded workload.
- You need labels on dashboards for ownership, team, and cost-center attribution, enforced by validation rather than convention.
- You want to parameterize a dashboard — drop in the service name, an instance filter, or a threshold — without hand-editing a 600-line JSON blob each time.
If you only ever need one dashboard that a single person maintains by hand, the console is enough. Reach for this module the moment a dashboard needs to be reproducible or reviewed.
Module structure
terraform-module-gcp-monitoring-dashboard/
├── versions.tf # provider + Terraform version pins
├── main.tf # google_monitoring_dashboard resource + JSON assembly
├── variables.tf # var-driven inputs with validation
└── outputs.tf # dashboard id/name + console URL
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# A standard label set merged onto every dashboard for ownership,
# cost attribution, and "this is managed by Terraform" discoverability.
base_labels = {
managed-by = "terraform"
team = var.team
cost-center = var.cost_center
environment = var.environment
}
labels = merge(local.base_labels, var.labels)
# Two ways to supply the dashboard body:
# 1. dashboard_json -> a ready JSON string (authored in the API schema)
# 2. dashboard_json_template + template_vars -> rendered via templatefile()
# Exactly one must be set; this is enforced by variable validation below.
rendered_json = var.dashboard_json_template != null ? templatefile(
var.dashboard_json_template,
var.template_vars,
) : var.dashboard_json
# Decode the supplied body so we can force-inject the displayName and labels
# the module owns, rather than trusting the caller to keep them in sync.
decoded = jsondecode(local.rendered_json)
dashboard_body = jsonencode(merge(
local.decoded,
{
displayName = var.display_name
labels = local.labels
},
# Pin the dashboard into a folder/group in the console when requested.
var.dashboard_filters == null ? {} : { dashboardFilters = var.dashboard_filters },
))
}
resource "google_monitoring_dashboard" "this" {
project = var.project_id
dashboard_json = local.dashboard_body
# Avoid a perpetual diff: the API normalizes the JSON (key ordering,
# injected defaults) on read. Only react to a change in display name or
# labels, which the module fully controls.
lifecycle {
ignore_changes = [dashboard_json]
precondition {
condition = can(jsondecode(local.rendered_json))
error_message = "The supplied dashboard body is not valid JSON."
}
precondition {
condition = contains(keys(local.decoded), "mosaicLayout") || contains(keys(local.decoded), "gridLayout") || contains(keys(local.decoded), "rowLayout") || contains(keys(local.decoded), "columnLayout")
error_message = "The dashboard JSON must contain exactly one layout key (mosaicLayout, gridLayout, rowLayout, or columnLayout)."
}
}
}
Note on
ignore_changes: Cloud Monitoring rewrites the stored JSON (sorting keys, addingwidth/heightdefaults, stamping anetag), which otherwise produces a never-ending plan diff. The module pins ownership-critical fields (displayName,labels) by re-injecting them throughlocals, and ignores the body itself. If you genuinely need every widget edit to apply, drop theignore_changesline in your fork and accept the noisier plans.
variables.tf
variable "project_id" {
description = "The GCP project ID where the dashboard is created."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id))
error_message = "project_id must be a valid GCP project ID (6-30 chars, lowercase letters, digits, hyphens)."
}
}
variable "display_name" {
description = "Human-readable name shown in the Cloud Monitoring dashboard list."
type = string
validation {
condition = length(var.display_name) > 0 && length(var.display_name) <= 75
error_message = "display_name must be between 1 and 75 characters."
}
}
variable "dashboard_json" {
description = "The dashboard body as a JSON string in the Cloud Monitoring API schema (must include one layout key and a widgets array). Mutually exclusive with dashboard_json_template."
type = string
default = null
}
variable "dashboard_json_template" {
description = "Path to a .tftpl/.json template rendered with templatefile() and template_vars. Mutually exclusive with dashboard_json."
type = string
default = null
}
variable "template_vars" {
description = "Variables passed to templatefile() when dashboard_json_template is used (e.g. service name, instance filter, thresholds)."
type = map(string)
default = {}
}
variable "dashboard_filters" {
description = "Optional list of dashboard-level filter objects (the API dashboardFilters field) for template variables such as a label-based selector."
type = list(object({
labelKey = string
templateVariable = optional(string)
stringValue = optional(string)
filterType = optional(string, "RESOURCE_LABEL")
}))
default = null
}
variable "team" {
description = "Owning team, applied as the 'team' label for routing and ownership."
type = string
validation {
condition = can(regex("^[a-z0-9_-]{1,63}$", var.team))
error_message = "team must match the GCP label-value rules (lowercase letters, digits, _ and -, max 63 chars)."
}
}
variable "cost_center" {
description = "Cost center applied as the 'cost-center' label for chargeback/showback."
type = string
default = "shared"
validation {
condition = can(regex("^[a-z0-9_-]{1,63}$", var.cost_center))
error_message = "cost_center must match GCP label-value rules (lowercase letters, digits, _ and -, max 63 chars)."
}
}
variable "environment" {
description = "Deployment environment, applied as the 'environment' label."
type = string
default = "prod"
validation {
condition = contains(["dev", "test", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, test, staging, prod."
}
}
variable "labels" {
description = "Extra labels merged onto the module's standard label set (caller values win on key collisions)."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The full resource ID, e.g. projects/{project}/dashboards/{dashboard_id}."
value = google_monitoring_dashboard.this.id
}
output "dashboard_id" {
description = "The server-assigned dashboard ID (the trailing segment of the resource name)."
value = element(split("/", google_monitoring_dashboard.this.id), length(split("/", google_monitoring_dashboard.this.id)) - 1)
}
output "display_name" {
description = "The display name applied to the dashboard."
value = var.display_name
}
output "console_url" {
description = "Deep link to open the dashboard in the Cloud Console."
value = "https://console.cloud.google.com/monitoring/dashboards/builder/${element(split("/", google_monitoring_dashboard.this.id), length(split("/", google_monitoring_dashboard.this.id)) - 1)}?project=${var.project_id}"
}
output "labels" {
description = "The effective label set applied to the dashboard."
value = local.labels
}
How to use it
Here a templatefile-rendered dashboard is stamped out for a single service, injecting the service name and a Cloud Run revision filter into a reusable golden-signals template. A downstream alert-policy documentation link then reuses the module’s console_url output so on-call engineers jump straight from the alert to the dashboard.
module "monitoring_dashboard" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-monitoring-dashboard?ref=v1.0.0"
project_id = "kv-payments-prod"
display_name = "Payments API — Golden Signals (prod)"
team = "payments"
cost_center = "fin-eng-4412"
environment = "prod"
# Reuse one template across every service; only the vars change.
dashboard_json_template = "${path.module}/dashboards/golden-signals.json.tftpl"
template_vars = {
service_name = "payments-api"
cloud_run_name = "payments-api"
latency_slo_ms = "350"
}
# A console template variable so viewers can slice by revision.
dashboard_filters = [{
labelKey = "revision_name"
templateVariable = "revision"
filterType = "RESOURCE_LABEL"
}]
labels = {
service = "payments-api"
tier = "1"
}
}
# Downstream: an alert policy whose documentation deep-links to the dashboard.
resource "google_monitoring_alert_policy" "latency" {
project = "kv-payments-prod"
display_name = "Payments API p95 latency > SLO"
combiner = "OR"
documentation {
mime_type = "text/markdown"
content = "p95 latency breached. Open the live dashboard: ${module.monitoring_dashboard.console_url}"
}
conditions {
display_name = "p95 latency"
condition_threshold {
filter = "resource.type = \"cloud_run_revision\" AND resource.labels.service_name = \"payments-api\" AND metric.type = \"run.googleapis.com/request_latencies\""
comparison = "COMPARISON_GT"
threshold_value = 350
duration = "300s"
aggregations {
alignment_period = "60s"
per_series_aligner = "ALIGN_PERCENTILE_95"
}
}
}
}
A minimal alternative passing a ready JSON string instead of a template:
module "monitoring_dashboard" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-monitoring-dashboard?ref=v1.0.0"
project_id = "kv-platform-prod"
display_name = "Platform — Ingress Overview"
team = "platform"
dashboard_json = file("${path.module}/dashboards/ingress-overview.json")
}
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 = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/monitoring_dashboard/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-monitoring-dashboard?ref=v1.0.0"
}
inputs = {
project_id = "..."
display_name = "..."
team = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/monitoring_dashboard && 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 |
|---|---|---|---|---|
project_id |
string |
— | Yes | GCP project ID where the dashboard is created. Validated against the project-ID format. |
display_name |
string |
— | Yes | Dashboard name shown in the console (1–75 chars). Force-injected into the JSON by the module. |
dashboard_json |
string |
null |
Conditional | Dashboard body as a JSON string in the API schema. Mutually exclusive with dashboard_json_template. |
dashboard_json_template |
string |
null |
Conditional | Path to a template rendered via templatefile(). Mutually exclusive with dashboard_json. |
template_vars |
map(string) |
{} |
No | Variables passed to templatefile() (service name, filters, thresholds). |
dashboard_filters |
list(object) |
null |
No | Dashboard-level template/filter variables (the API dashboardFilters field). |
team |
string |
— | Yes | Owning team; applied as the team label. Validated against GCP label rules. |
cost_center |
string |
"shared" |
No | Cost center; applied as the cost-center label for chargeback. |
environment |
string |
"prod" |
No | One of dev, test, staging, prod; applied as the environment label. |
labels |
map(string) |
{} |
No | Extra labels merged onto the standard set (caller wins on collisions). |
Validation note: supply either
dashboard_jsonordashboard_json_template. The module’stemplatefile()/jsondecode()path and thepreconditionon layout keys will fail fast if both are null or the body is malformed.
Outputs
| Name | Description |
|---|---|
id |
Full resource ID: projects/{project}/dashboards/{dashboard_id}. |
dashboard_id |
Server-assigned dashboard ID (trailing segment of the resource name). |
display_name |
The display name applied to the dashboard. |
console_url |
Deep link to open the dashboard in the Cloud Console. |
labels |
The effective label set applied to the dashboard. |
Enterprise scenario
A fintech platform team runs ~60 GCP projects, one per product squad. They publish a single golden-signals.json.tftpl template (latency, traffic, errors, saturation as a mosaicLayout) and call this module from each squad’s stack, passing only the service name, Cloud Run revision filter, and latency SLO via template_vars. Every project then gets an identical, reviewed dashboard labeled with team and cost-center, and the platform’s alert policies deep-link to it via the console_url output — so when latency breaches, on-call lands on the exact live chart instead of hunting through the console.
Best practices
- Author widgets in the API schema, version the template. Keep
dashboard_json/*.tftplfiles next to the module call so chart changes are diffable in PRs; export the JSON from a console-built dashboard once, then never hand-edit it in the UI again (UI edits cause drift the module deliberately ignores). - Keep
ignore_changes = [dashboard_json]unless you mean it. Cloud Monitoring normalizes and re-stamps the JSON server-side; without it you get a permanent plan diff. The module still pinsdisplayNameandlabelsdeterministically, so ownership metadata never drifts. - Label every dashboard for ownership and cost. The enforced
team/cost-center/environmentlabels let you filter dashboards by squad and attribute the (small) Monitoring API/storage cost back to the right budget — invaluable across dozens of projects. - Mind dashboard and widget quotas. A project caps the number of custom dashboards and widgets-per-dashboard; favor a few focused
mosaicLayoutdashboards over one mega-board, and let the module stamp per-service instances rather than cramming every service onto one page. - Prefer MQL or PromQL queries with explicit alignment inside widgets for SLO charts so percentiles and rates match your alert policies exactly — a chart and its alerting condition should be computed the same way, or on-call loses trust in both.
- Pin the module by tag (
?ref=v1.0.0) and the provider with~> 5.0. Dashboard JSON schema fields evolve with the provider; pinning keeps a template that renders today from silently changing meaning on the nextterraform init.