Cloud Engineering

Share CloudWatch Metrics as Images via Lambda + API Gateway (Terraform)

Intermediate35 min to complete10 min readJune 24, 2026

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.

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)
AWS
CloudWatch
Lambda
API Gateway
Terraform
Observability

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:GetMetricWidgetImage and returns a PNG
  • An HTTP API Gateway route (GET /metric) that fronts the Lambda — HTTP APIs return binary responses natively, no binary_media_types juggling
  • 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: GetMetricWidgetImage is a billable CloudWatch API call, and the Lambda + API Gateway invocations are billable too. We set a Cache-Control header 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.

hcl
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

hcl
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}
hcl
# 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.

python
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 tableGetMetricWidgetImage will happily render any metric in your account. If you let the caller pass raw MetricWidget JSON 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.

hcl
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.

hcl
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.

hcl
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

hcl
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

bash
terraform init
terraform apply

Step 10: Test and Share

Fetch a graph as a file:

bash
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 Linux

You should get a real PNG of your Lambda errors-vs-invocations graph over the last 6 hours. Now embed it anywhere:

markdown
<!-- In a README or any Markdown -->
![Lambda errors](https://abc123.execute-api.us-east-1.amazonaws.com/metric?widget=lambda-errors&hours=24)
html
<!-- 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 MetricWidget JSON 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 return 403 on mismatch.
  • Put CloudFront in front of it with a generous cache TTL. This cuts your GetMetricWidgetImage bill (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. The Cache-Control: max-age=60 header 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) without binary_media_types = ["*/*"]. On HTTP APIs the flag alone is enough.
  • AccessDeniedException invoking Lambda — the aws_lambda_permission for apigateway.amazonaws.com is missing or its source_arn doesn't match.
  • AccessDenied from CloudWatch in the logs — the role is missing cloudwatch: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

bash
terraform destroy

Everything 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:

python
cw_eu = boto3.client("cloudwatch", region_name="eu-west-1")
widget["region"] = "eu-west-1"   # must match the client's region

The 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

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.