Share CloudWatch Metrics as Images via Lambda + API Gateway (Terraform)
Quick answer
Turn any CloudWatch graph into a public PNG URL you can embed in a README, status page, or Slack — without handing out AWS console access. Built with the GetMetricWidgetImage API, Lambda, an HTTP API Gateway, and Terraform.
- Step 1: Project Structure
- Step 2: versions.tf — Provider Pins
- Step 3: variables.tf and main.tf
- Step 4: src/main.py — The Lambda Handler
- Step 5: iam.tf — Least-Privilege Role
intermediate · 35 min
Before you begin
- Terraform >= 1.6 installed
- AWS CLI configured with permissions to create IAM roles, Lambda, and API Gateway
- An AWS account already emitting CloudWatch metrics (e.g. a Lambda or API Gateway)
- Basic Terraform knowledge (init, plan, apply)
CloudWatch graphs live behind the AWS console. If you want to put one on a public status page, a project README, a Notion doc, or drop it into a Slack channel, you normally have to screenshot it by hand — and the screenshot is stale the moment you take it.
The GetMetricWidgetImage API solves this: you hand it a metric widget definition and it renders a live PNG of the graph. Wrap it in a Lambda behind an HTTP API Gateway and you get a plain URL — https://.../metric?widget=lambda-errors — that returns a fresh image every time it's loaded. Embed it anywhere an <img> tag works, with nobody touching your AWS account.
This tutorial builds that endpoint end to end in Terraform.
What You'll Build
- A Lambda (Python 3.12) that calls
cloudwatch:GetMetricWidgetImageand returns a PNG - An HTTP API Gateway route (
GET /metric) that fronts the Lambda — HTTP APIs return binary responses natively, nobinary_media_typesjuggling - Least-privilege IAM scoped to the single CloudWatch action
- A handful of server-side widget presets so callers pick a graph by name and can never render arbitrary metrics from your account
The result is one output: a public URL that streams a live CloudWatch graph as an image.
Cost note:
GetMetricWidgetImageis a billable CloudWatch API call, and the Lambda + API Gateway invocations are billable too. We set aCache-Controlheader in Step 4 and discuss putting CloudFront in front of it in the security section — do that before you embed the URL on a high-traffic page.
Step 1: Project Structure
cloudwatch-image/
├── versions.tf
├── variables.tf
├── main.tf
├── iam.tf
├── lambda.tf
├── apigw.tf
├── outputs.tf
└── src/
└── main.py
Step 2: versions.tf — Provider Pins
We need the archive provider to zip the Lambda source alongside aws.
1terraform {
2 required_version = ">= 1.6"
3
4 required_providers {
5 aws = {
6 source = "hashicorp/aws"
7 version = "~> 5.60"
8 }
9 archive = {
10 source = "hashicorp/archive"
11 version = "~> 2.4"
12 }
13 }
14}Step 3: variables.tf and main.tf
1# variables.tf
2variable "region" {
3 description = "Region to deploy into AND read metrics from"
4 type = string
5 default = "us-east-1"
6}
7
8variable "function_name" {
9 type = string
10 default = "cloudwatch-metric-image"
11}
12
13variable "log_retention_days" {
14 type = number
15 default = 14
16}# main.tf
provider "aws" {
region = var.region
}Step 4: src/main.py — The Lambda Handler
This is the core of the build. The handler reads a widget name from the query string, looks it up in a server-side preset table, builds a MetricWidget JSON document, and asks CloudWatch to render it.
1import base64
2import json
3import os
4
5import boto3
6
7cloudwatch = boto3.client("cloudwatch")
8# AWS_REGION is injected by the Lambda runtime — never set it yourself.
9REGION = os.environ["AWS_REGION"]
10
11
12def _presets():
13 """Curated graphs. Callers select one by name; they cannot define their own."""
14 return {
15 "lambda-errors": {
16 "title": "Lambda — Errors vs Invocations",
17 "metrics": [
18 ["AWS/Lambda", "Invocations", {"stat": "Sum", "color": "#1f77b4"}],
19 ["AWS/Lambda", "Errors", {"stat": "Sum", "color": "#d62728"}],
20 ],
21 },
22 "api-latency": {
23 "title": "API Gateway — p95 Latency (ms)",
24 "metrics": [
25 ["AWS/ApiGateway", "Latency", {"stat": "p95"}],
26 ],
27 },
28 }
29
30
31def _clamp_hours(raw, default=3, lo=1, hi=168):
32 try:
33 return max(lo, min(int(raw), hi))
34 except (TypeError, ValueError):
35 return default
36
37
38def handler(event, context):
39 params = event.get("queryStringParameters") or {}
40 name = params.get("widget", "lambda-errors")
41 hours = _clamp_hours(params.get("hours"))
42
43 preset = _presets().get(name)
44 if preset is None:
45 return {
46 "statusCode": 404,
47 "headers": {"Content-Type": "text/plain"},
48 "body": f"unknown widget '{name}'",
49 }
50
51 widget = {
52 "title": preset["title"],
53 "metrics": preset["metrics"],
54 "view": "timeSeries",
55 "stacked": False,
56 "region": REGION,
57 "period": 300,
58 "width": 1000,
59 "height": 400,
60 "start": f"-PT{hours}H",
61 "end": "-PT0H", # current time; matches the format in the AWS API docs
62 }
63
64 resp = cloudwatch.get_metric_widget_image(
65 MetricWidget=json.dumps(widget),
66 OutputFormat="png",
67 )
68 image = resp["MetricWidgetImage"] # raw PNG bytes
69
70 return {
71 "statusCode": 200,
72 "headers": {
73 "Content-Type": "image/png",
74 "Cache-Control": "public, max-age=60",
75 },
76 "isBase64Encoded": True,
77 "body": base64.b64encode(image).decode("utf-8"),
78 }Two details that make this work:
isBase64Encoded: True— API Gateway base64-decodes the body back into raw bytes before sending it to the client. Skip this and callers get a wall of base64 text instead of an image.- The preset table —
GetMetricWidgetImagewill happily render any metric in your account. If you let the caller pass rawMetricWidgetJSON through the query string, anyone who finds the URL can graph your billing, your private namespaces, anything. Presets keep the blast radius to exactly the graphs you chose to publish.
Step 5: iam.tf — Least-Privilege Role
The role grants exactly one CloudWatch action plus log writes — nothing else.
1data "aws_iam_policy_document" "assume" {
2 statement {
3 actions = ["sts:AssumeRole"]
4 principals {
5 type = "Service"
6 identifiers = ["lambda.amazonaws.com"]
7 }
8 }
9}
10
11resource "aws_iam_role" "lambda" {
12 name = "${var.function_name}-role"
13 assume_role_policy = data.aws_iam_policy_document.assume.json
14}
15
16resource "aws_cloudwatch_log_group" "lambda" {
17 name = "/aws/lambda/${var.function_name}"
18 retention_in_days = var.log_retention_days
19}
20
21data "aws_iam_policy_document" "permissions" {
22 statement {
23 sid = "CloudWatchMetricImage"
24 actions = ["cloudwatch:GetMetricWidgetImage"]
25 # This action does not support resource-level permissions.
26 resources = ["*"]
27 }
28
29 statement {
30 sid = "Logs"
31 actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
32 resources = ["${aws_cloudwatch_log_group.lambda.arn}:*"]
33 }
34}
35
36resource "aws_iam_role_policy" "lambda" {
37 name = "${var.function_name}-policy"
38 role = aws_iam_role.lambda.id
39 policy = data.aws_iam_policy_document.permissions.json
40}Creating the log group ourselves (instead of letting Lambda auto-create it) lets us set a retention period and scope the logs: permissions to it.
Step 6: lambda.tf — Package and Deploy
The archive_file data source zips src/main.py at plan time, and source_code_hash makes Terraform redeploy whenever the code changes.
1data "archive_file" "lambda" {
2 type = "zip"
3 source_file = "${path.module}/src/main.py"
4 output_path = "${path.module}/build/lambda.zip"
5}
6
7resource "aws_lambda_function" "metric_image" {
8 function_name = var.function_name
9 role = aws_iam_role.lambda.arn
10 runtime = "python3.12"
11 handler = "main.handler"
12
13 filename = data.archive_file.lambda.output_path
14 source_code_hash = data.archive_file.lambda.output_base64sha256
15
16 timeout = 10
17 memory_size = 256
18
19 depends_on = [
20 aws_iam_role_policy.lambda,
21 aws_cloudwatch_log_group.lambda,
22 ]
23}Step 7: apigw.tf — HTTP API Gateway
HTTP APIs (apigatewayv2) are cheaper than REST APIs and return binary payloads natively when the Lambda sets isBase64Encoded — no binary_media_types configuration required.
1resource "aws_apigatewayv2_api" "this" {
2 name = "${var.function_name}-api"
3 protocol_type = "HTTP"
4}
5
6resource "aws_apigatewayv2_integration" "lambda" {
7 api_id = aws_apigatewayv2_api.this.id
8 integration_type = "AWS_PROXY"
9 integration_uri = aws_lambda_function.metric_image.invoke_arn
10 payload_format_version = "2.0"
11}
12
13resource "aws_apigatewayv2_route" "metric" {
14 api_id = aws_apigatewayv2_api.this.id
15 route_key = "GET /metric"
16 target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
17}
18
19resource "aws_apigatewayv2_stage" "default" {
20 api_id = aws_apigatewayv2_api.this.id
21 name = "$default"
22 auto_deploy = true
23}
24
25resource "aws_lambda_permission" "apigw" {
26 statement_id = "AllowAPIGatewayInvoke"
27 action = "lambda:InvokeFunction"
28 function_name = aws_lambda_function.metric_image.function_name
29 principal = "apigateway.amazonaws.com"
30 source_arn = "${aws_apigatewayv2_api.this.execution_arn}/*/*"
31}The aws_lambda_permission is the piece people forget — without it API Gateway gets an AccessDeniedException when it tries to invoke the function.
Step 8: outputs.tf
output "metric_image_url" {
description = "Public URL that returns a CloudWatch graph as a PNG"
value = "${aws_apigatewayv2_stage.default.invoke_url}/metric"
}Step 9: Deploy
terraform init
terraform applyStep 10: Test and Share
Fetch a graph as a file:
URL=$(terraform output -raw metric_image_url)
curl -s "$URL?widget=lambda-errors&hours=6" -o metric.png
open metric.png # macOS; use xdg-open on LinuxYou should get a real PNG of your Lambda errors-vs-invocations graph over the last 6 hours. Now embed it anywhere:
<!-- In a README or any Markdown -->
<!-- On a status page, refreshed every 60s -->
<img src="https://abc123.execute-api.us-east-1.amazonaws.com/metric?widget=api-latency"
alt="API p95 latency" width="1000" height="400" />Because the image is generated on each request, the graph is always current — no cron job, no screenshot, no stale dashboard export.
Securing a Public Metric Endpoint
This URL is unauthenticated by default, and the image reveals real metric values. Before you publish it, decide how exposed it should be:
- Keep the preset allow-list. Never accept raw
MetricWidgetJSON from the query string — that turns the endpoint into "render any metric in the account." - Add a shared secret for semi-private use: require a
?token=...query param the Lambda checks against a value stored in SSM Parameter Store or Secrets Manager, and return403on mismatch. - Put CloudFront in front of it with a generous cache TTL. This cuts your
GetMetricWidgetImagebill (identical requests are served from cache), shields you from the API's account-wide limit of 20 transactions per second (a popular embedded image can blow through that uncached), and gives you a place to attach AWS WAF rate-limiting. TheCache-Control: max-age=60header we set in Step 4 already tells CloudFront how long to cache each image. - Treat the data as sensitive. Error counts and latency can leak more about your system than you'd expect — only publish graphs you'd be comfortable showing a competitor.
Common Issues
- Response is base64 text, not an image — you dropped
isBase64Encoded: True, or you're on a REST API (apigateway v1) withoutbinary_media_types = ["*/*"]. On HTTP APIs the flag alone is enough. AccessDeniedExceptioninvoking Lambda — theaws_lambda_permissionforapigateway.amazonaws.comis missing or itssource_arndoesn't match.AccessDeniedfrom CloudWatch in the logs — the role is missingcloudwatch:GetMetricWidgetImage.- Blank or empty graph — there's no data for that metric in the time window, or the namespace/metric name/region is wrong. Confirm the metric exists in the same region you deployed to.
Tear Down
terraform destroyEverything here is serverless and pay-per-use, so there's no idle cost — but destroying keeps the account tidy and removes the public endpoint.
Frequently Asked Questions
Can I use a REST API Gateway instead of an HTTP API?
Yes, but it's more work. On a REST API (apigateway v1) you must declare binary_media_types = ["*/*"] on the aws_api_gateway_rest_api, and the client's Accept header has to match a configured binary type for API Gateway to return raw bytes. HTTP APIs return binary automatically from isBase64Encoded, which is why this tutorial uses them. If you're already standing up a REST API for other reasons, the AWS Lambda serverless patterns guide covers the v1 integration in more depth.
How do I graph a metric from a different AWS region?
You call CloudWatch in that region. The API docs are explicit that the widget's region field must specify the local Region of the request — you can't call us-east-1 and render eu-west-1 data through the region field alone. So create a client for the target region and set a matching widget region:
cw_eu = boto3.client("cloudwatch", region_name="eu-west-1")
widget["region"] = "eu-west-1" # must match the client's regionThe Lambda itself can run anywhere; only the CloudWatch call has to target the metrics' Region. cloudwatch:GetMetricWidgetImage isn't resource- or region-scoped in IAM, so no policy change is needed.
Can it render a whole CloudWatch dashboard, not just one graph?
No — GetMetricWidgetImage renders a single metric widget. There's no API that exports an entire dashboard as one image. To share a multi-graph view, expose several presets and embed several <img> tags, or compose multiple metrics into one widget. For richer, queryable telemetry you'd typically reach past CloudWatch images entirely — see AWS CloudWatch Container Insights & X-Ray and, for how CloudWatch compares to audit logging, CloudWatch vs CloudTrail.
Is the returned image live or cached?
It's generated on every request, so it's always current. The Cache-Control: public, max-age=60 header we set just tells browsers and any CDN in front of the endpoint they may reuse an image for up to 60 seconds — which is what keeps your GetMetricWidgetImage bill flat on a high-traffic page.
I'm new to Terraform on AWS — where should I start?
This tutorial assumes you can run init/plan/apply. If that's shaky, build something with more moving parts first: Provisioning an EKS Cluster with Terraform from Scratch walks through providers, IAM, and modules end to end, and the Terraform EKS infrastructure guide covers the patterns this build reuses.
Official References
- GetMetricWidgetImage API Reference — The CloudWatch action that renders a metric graph to PNG, including request/response shape
- Metric Widget Structure — Full JSON schema for the
MetricWidgetdocument (metrics, view, period, annotations, and more) - Working with binary media types for HTTP APIs — How API Gateway handles
isBase64EncodedLambda responses - Terraform aws_apigatewayv2_api — Provider docs for the HTTP API resources used here
- boto3 CloudWatch.get_metric_widget_image — The Python SDK call the Lambda uses
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.