IaC GCP

Terraform Module: GCP Monitoring Dashboard — version-controlled observability you can ship per service

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

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, adding width/height defaults, stamping an etag), which otherwise produces a never-ending plan diff. The module pins ownership-critical fields (displayName, labels) by re-injecting them through locals, and ignores the body itself. If you genuinely need every widget edit to apply, drop the ignore_changes line 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 configlive/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 configlive/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_json or dashboard_json_template. The module’s templatefile()/jsondecode() path and the precondition on 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

TerraformGCPMonitoring DashboardModuleIaC
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