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
- You want dashboards in version control — reviewed in PRs, diffed over time, and recreated identically across accounts — instead of click-ops panels that drift and disappear when someone leaves.
- You are building a service template where every microservice ships with a standard dashboard (latency, error rate, saturation) generated from the same module with a few inputs.
- You need to mix metric graphs, Markdown runbook panels, and Logs Insights queries on one page and want a typed, validated way to lay them out on the 24-column grid.
- You operate multi-Region and want a single global dashboard that pulls metrics from several Regions, defined once and applied from one stack.
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
regionkey 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 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/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
- Never hand-write the JSON — let
jsonencodedo it. Model widgets as the typedwidgetsvariable so Terraform validates types and positions; the module renders flawlessdashboard_bodyJSON and you get a clean diff on every plan. - Lay widgets out deliberately on the 24-column grid. The module’s validation enforces
x + width <= 24; group related panels into rows, reserve a full-width row for a Markdown header with the runbook link, and keep heights consistent for a scannable page. - Set
regionon cross-Region widgets. A dashboard is global and can chart metrics from any Region — always setproperties.regionon metric and log widgets so panels resolve against the right Region, and omit it on text panels where CloudWatch ignores it. - Pick the right
statandperiod. Usep99/p95for latency,Sumfor counts, andAveragefor utilization; alignperiodwith the metric’s native resolution (300s for standard, 60s for detailed/custom) so graphs are neither noisy nor lossy. - Stay aware of the free tier. The first 3 dashboards are free; compose a small number of rich, code-managed dashboards instead of many console one-offs, and delete abandoned dashboards via Terraform so they don’t quietly add cost.
- Pin the module and pair it with alarms. Use
?ref=<tag>, name dashboards by<service>-<env>-<role>convention, and treat dashboards as the human-facing companion toaws_cloudwatch_metric_alarmresources — the dashboard shows the trend, the alarm pages the on-call.