IaC AWS

Terraform Module: AWS CloudWatch Dashboard — Typed Widgets, jsonencode, and Dashboards as Code

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_cloudwatch_dashboard that builds metric, text, and log-query widgets from a typed variable and renders dashboard_body with jsonencode — observability you can review in a PR. 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 "dashboard" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudwatch-dashboard?ref=v1.0.0"

  dashboard_name = "..."   # Dashboard name (unique per account; dashboards are global).

  widgets = [
    {
      type   = "metric"     # metric | text | log
      x      = 0
      y      = 0
      width  = 12
      height = 6
      properties = {
        title   = "..."      # Widget title shown on the dashboard.
        region  = "..."      # Region the metrics live in, e.g. us-east-1.
        metrics = [["AWS/EC2", "CPUUtilization", "InstanceId", "..."]]
      }
    },
  ]
}

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

What this module is

An Amazon CloudWatch Dashboard (aws_cloudwatch_dashboard) is a customizable home for graphs, single-value numbers, free-text annotations, and Logs Insights query results — the at-a-glance view your on-call engineer opens first during an incident. The resource itself is deceptively thin: it has a dashboard_name and a single dashboard_body argument, and that body is a JSON document describing an array of widgets, each with a position (x, y, width, height on a 24-column grid) and a properties object whose shape depends on the widget type.

That JSON is where dashboards-as-code usually goes wrong. People paste a giant blob exported from the console, lose all type safety, and end up with an unreadable, un-reviewable string that nobody dares to edit. The clean pattern — and what this module does — is to model the dashboard as a typed Terraform variable (a list of widget objects), let Terraform validate each widget, and then render the final JSON with jsonencode so you never hand-write a brace. A reviewer reads a tidy HCL list in the pull request; CloudWatch receives perfectly-formed JSON.

The module understands the three widget types that cover the vast majority of real dashboards: metric widgets (one or more CloudWatch metrics graphed with a period, stat, and region), text widgets (Markdown panels for runbook links and section headers), and log widgets (Logs Insights queries rendered as a table or time series). Each is described declaratively; the module assembles the widgets array and jsonencodes it into dashboard_body.

One pricing note worth baking into your mental model: CloudWatch dashboards are an account-global, Region-agnostic resource (a dashboard can chart metrics from any Region), and the first 3 dashboards are free; beyond that they are billed per dashboard per month. That makes a small number of well-composed, code-managed dashboards far better than dozens of console-clicked one-offs.

When to use it

Reach for Grafana (self-managed or Amazon Managed Grafana) instead when you need cross-source dashboards (Prometheus, X-Ray, third-party), rich templating/variables, or alerting workflows beyond CloudWatch. This module is the right tool when CloudWatch is your metrics store and you want native dashboards managed as code.

Module structure

terraform-module-aws-cloudwatch-dashboard/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # locals build widget JSON; aws_cloudwatch_dashboard
├── variables.tf     # typed widgets variable with validations
└── outputs.tf       # name, arn, and the rendered body

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Transform the typed widget list into the exact shape CloudWatch expects.
  # Each widget carries a position (x/y/width/height on a 24-col grid) and a
  # type-specific `properties` object. Unset properties are dropped so the
  # final JSON contains only keys the caller actually set.
  rendered_widgets = [
    for w in var.widgets : {
      type   = w.type
      x      = w.x
      y      = w.y
      width  = w.width
      height = w.height

      properties = merge(
        # ---- Common to metric/log: a title and a region ----
        w.properties.title == null ? {} : { title = w.properties.title },
        w.properties.region == null ? {} : { region = w.properties.region },

        # ---- metric widgets: metrics array, view, stacked, period, stat ----
        w.type != "metric" ? {} : merge(
          { metrics = w.properties.metrics },
          w.properties.view == null ? {} : { view = w.properties.view },
          w.properties.stacked == null ? {} : { stacked = w.properties.stacked },
          w.properties.period == null ? {} : { period = w.properties.period },
          w.properties.stat == null ? {} : { stat = w.properties.stat },
          w.properties.y_axis == null ? {} : { yAxis = w.properties.y_axis },
        ),

        # ---- text widgets: a Markdown string ----
        w.type != "text" ? {} : {
          markdown = w.properties.markdown
        },

        # ---- log widgets: a Logs Insights query string + view ----
        w.type != "log" ? {} : merge(
          { query = w.properties.query },
          { view = coalesce(w.properties.view, "table") },
        ),
      )
    }
  ]
}

resource "aws_cloudwatch_dashboard" "this" {
  dashboard_name = var.dashboard_name

  # jsonencode renders perfectly-formed JSON from the HCL object above — no
  # hand-written braces, no quoting bugs, and a clean diff in every plan.
  dashboard_body = jsonencode({
    widgets = local.rendered_widgets
  })
}

variables.tf

variable "dashboard_name" {
  description = "Name of the CloudWatch dashboard (unique per account; dashboards are global)."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9_-]{1,255}$", var.dashboard_name))
    error_message = "dashboard_name must be 1-255 chars of letters, digits, hyphens, and underscores (no spaces)."
  }
}

variable "widgets" {
  description = <<-EOT
    Ordered list of dashboard widgets. Each widget:
      type   - "metric" | "text" | "log"
      x      - column offset on the 24-wide grid (0-23)
      y      - row offset (0-based)
      width  - widget width in grid columns (1-24)
      height - widget height in grid rows (1-1000)
      properties:
        title    - widget title (metric/log)
        region   - source Region, e.g. "us-east-1" (metric/log)
        # metric-only:
        metrics  - list of metric arrays, e.g. [["AWS/EC2","CPUUtilization","InstanceId","i-..."]]
        view     - "timeSeries" | "singleValue" | "bar" | "pie" (metric); "table" | "timeSeries" (log)
        stacked  - stack series on metric graphs (bool)
        period   - aggregation period in seconds (e.g. 300)
        stat     - statistic, e.g. "Average", "Sum", "p99"
        y_axis   - optional yAxis object passed through verbatim
        # text-only:
        markdown - Markdown body of a text panel
        # log-only:
        query    - CloudWatch Logs Insights query string
  EOT
  type = list(object({
    type   = string
    x      = number
    y      = number
    width  = number
    height = number
    properties = object({
      title    = optional(string)
      region   = optional(string)
      metrics  = optional(list(list(string)))
      view     = optional(string)
      stacked  = optional(bool)
      period   = optional(number)
      stat     = optional(string)
      y_axis   = optional(any)
      markdown = optional(string)
      query    = optional(string)
    })
  }))

  validation {
    condition = alltrue([
      for w in var.widgets : contains(["metric", "text", "log"], w.type)
    ])
    error_message = "Each widget.type must be one of: metric, text, log."
  }

  validation {
    condition = alltrue([
      for w in var.widgets :
      w.x >= 0 && w.x <= 23 &&
      w.y >= 0 &&
      w.width >= 1 && w.width <= 24 &&
      (w.x + w.width) <= 24 &&
      w.height >= 1 && w.height <= 1000
    ])
    error_message = "Each widget must fit the 24-column grid: 0<=x<=23, width 1-24, x+width<=24, y>=0, height 1-1000."
  }

  validation {
    condition = alltrue([
      for w in var.widgets :
      w.type != "metric" || (w.properties.metrics != null && length(w.properties.metrics) > 0)
    ])
    error_message = "Every metric widget must define a non-empty properties.metrics list."
  }

  validation {
    condition = alltrue([
      for w in var.widgets :
      w.type != "text" || (w.properties.markdown != null && w.properties.markdown != "")
    ])
    error_message = "Every text widget must define properties.markdown."
  }

  validation {
    condition = alltrue([
      for w in var.widgets :
      w.type != "log" || (w.properties.query != null && w.properties.query != "")
    ])
    error_message = "Every log widget must define properties.query."
  }
}

outputs.tf

output "dashboard_name" {
  description = "Name of the CloudWatch dashboard."
  value       = aws_cloudwatch_dashboard.this.dashboard_name
}

output "dashboard_arn" {
  description = "ARN of the CloudWatch dashboard."
  value       = aws_cloudwatch_dashboard.this.dashboard_arn
}

output "dashboard_body" {
  description = "The rendered JSON dashboard body (handy for debugging the layout)."
  value       = aws_cloudwatch_dashboard.this.dashboard_body
}

output "console_url" {
  description = "Direct console URL to the dashboard."
  value       = "https://console.aws.amazon.com/cloudwatch/home#dashboards:name=${aws_cloudwatch_dashboard.this.dashboard_name}"
}

How to use it

module "orders_dashboard" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudwatch-dashboard?ref=v1.0.0"

  dashboard_name = "orders-prod-overview"

  widgets = [
    # A Markdown header with a runbook link spanning the full width.
    {
      type   = "text"
      x      = 0
      y      = 0
      width  = 24
      height = 2
      properties = {
        markdown = "# Orders Service — Production\nOn-call runbook: https://wiki.example.com/orders/runbook"
      }
    },

    # ALB request count + 5xx errors as a stacked time series.
    {
      type   = "metric"
      x      = 0
      y      = 2
      width  = 12
      height = 6
      properties = {
        title   = "ALB Requests & 5xx"
        region  = "us-east-1"
        view    = "timeSeries"
        stacked = false
        period  = 300
        stat    = "Sum"
        metrics = [
          ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "app/orders-alb/abc123"],
          ["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count", "LoadBalancer", "app/orders-alb/abc123"],
        ]
      }
    },

    # p99 target response time as a single big number.
    {
      type   = "metric"
      x      = 12
      y      = 2
      width  = 12
      height = 6
      properties = {
        title   = "Target p99 Latency (s)"
        region  = "us-east-1"
        view    = "singleValue"
        period  = 300
        stat    = "p99"
        metrics = [
          ["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", "app/orders-alb/abc123"],
        ]
      }
    },

    # Logs Insights: top 20 application error lines as a table.
    {
      type   = "log"
      x      = 0
      y      = 8
      width  = 24
      height = 6
      properties = {
        title  = "Recent Application Errors"
        region = "us-east-1"
        view   = "table"
        query  = "SOURCE '/ecs/orders-prod' | fields @timestamp, @message | filter @message like /ERROR/ | sort @timestamp desc | limit 20"
      }
    },
  ]
}

# Downstream: surface the dashboard URL in an SSM parameter for the runbook.
resource "aws_ssm_parameter" "orders_dashboard_url" {
  name  = "/orders/prod/dashboard-url"
  type  = "String"
  value = module.orders_dashboard.console_url
}

CloudWatch ignores the region key on text widgets and requires it on metric/log widgets pointing at another Region — the module passes it through only where it belongs.

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 = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  dashboard_name = "..."
  widgets = [
    # ...typed widget objects per environment...
  ]
}

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

cd live/prod/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
dashboard_name string Yes Dashboard name (unique per account; global).
widgets list(object) Yes Ordered list of typed widgets (metric/text/log) with grid position and properties.

widgets[*] object

Field Type Applies to Description
type string all metric, text, or log.
x / y number all Position on the 24-column grid.
width / height number all Size in grid columns/rows.
properties.title string metric/log Widget title.
properties.region string metric/log Source Region, e.g. us-east-1.
properties.metrics list(list(string)) metric Metric arrays, e.g. [["AWS/EC2","CPUUtilization","InstanceId","i-…"]].
properties.view string metric/log timeSeries/singleValue/bar/pie (metric) or table/timeSeries (log).
properties.stacked bool metric Stack series on the graph.
properties.period number metric Aggregation period in seconds.
properties.stat string metric Statistic (Average, Sum, p99, …).
properties.y_axis any metric Optional yAxis object passed through verbatim.
properties.markdown string text Markdown body of the panel.
properties.query string log Logs Insights query string.

Outputs

Name Description
dashboard_name Name of the CloudWatch dashboard.
dashboard_arn ARN of the dashboard.
dashboard_body Rendered JSON body (useful for debugging layout).
console_url Direct console URL to the dashboard.

Enterprise scenario

A SaaS platform runs about twenty-five microservices, each owned by a different squad. The observability team publishes this module at v1.0.0 together with a small wrapper that, given a service name and its key resources, emits a standard four-row dashboard: a Markdown header with the runbook link, request/error metric graphs, a single-value p99 latency tile, and a Logs Insights error table. Because the dashboard is a typed variable rendered with jsonencode, a squad adding a new panel opens a three-line PR instead of pasting an unreadable JSON blob, and the diff is reviewable line-by-line. The platform keeps the account under the 3-free-dashboard line per environment by composing one rich overview per service tier rather than dozens of one-offs, and every dashboard is recreated identically when a new Region or account is bootstrapped.

Best practices

TerraformAWSCloudWatch 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