Terraform Lesson 56 of 89

Terraform on AWS: CloudWatch Alarms, Dashboards & Logs, SNS Alerting & SRE Observability as Code

An alarm you clicked together in the console is an alarm nobody else can see, review, or reproduce. It has no history in git, no owner, no diff when someone widens the threshold at 2 a.m. to stop the pages, and no way to be identical on the other forty instances in the fleet. That is the whole reason observability-as-code exists: the alarm thresholds, the SNS topics, the log-retention policy, the dashboard JSON and the auto-remediation wiring are infrastructure, and infrastructure belongs in Terraform next to the thing it watches — versioned, peer-reviewed, and stamped identically into every environment from one module. This lesson builds that plane end to end on AWS.

By the end you will have stood up, from an empty directory, a real aws_cloudwatch_metric_alarm on an EC2 instance’s CPU, an SNS topic with a confirmed email subscription wired to the alarm’s alarm_actions, a log group with a sane retention_in_days and a metric filter that turns an ERROR log line into its own alarm, a composite alarm that only pages when two conditions are both true, and a CloudWatch dashboard rendered from a jsonencode widget body — then you will deliberately trigger the alarm and watch the notification arrive, before tearing it all down with terraform destroy. You will leave knowing the arguments that decide whether an alarm is trustworthy or noisy (evaluation_periods, datapoints_to_alarm, treat_missing_data), the exact metric to alarm on for each service, and the fistful of gotchas — an unconfirmed email subscription, a KMS-encrypted topic CloudWatch can’t publish to, a log group billing you forever — that eat an afternoon the first time.

This is the provider-specific, SRE-flavoured layer of the KloudVin Terraform course. It assumes you already know core Terraform — HCL, providers, resources, variables, state and modules from the Foundation and Intermediate tiers — and applies it to AWS relentlessly, with copy-pasteable .tf files and a terraform init → plan → apply → verify → destroy you actually run.

What you’ll build

The scenario is the one every on-call rotation lives inside: a small web service on AWS — an EC2 instance (or an Auto Scaling group) behind an Application Load Balancer, with an RDS database behind it — and a requirement that the team find out when any of it degrades, through the same channel, with the same thresholds, in dev and in prod, without anyone clicking around a console. That means metrics turned into alarms, application logs shipped to a retained log group, a couple of patterns in those logs promoted to alarms of their own, one dashboard the team actually looks at, and a single notification path — an SNS topic — that fans a firing alarm out to email and Slack, and can hand the nastier ones to a Lambda that fixes the problem before a human wakes up.

Everything in that paragraph is a Terraform resource. The metric alarm is aws_cloudwatch_metric_alarm; the composite is aws_cloudwatch_composite_alarm; the notification topic is aws_sns_topic with aws_sns_topic_subscription; the retained logs are aws_cloudwatch_log_group; the log-pattern-to-alarm bridge is aws_cloudwatch_log_metric_filter; the dashboard is aws_cloudwatch_dashboard; the event-driven ops rule is aws_cloudwatch_event_rule. Wiring them by hand in the console is slow and invisible; wiring them in Terraform gives you the dependency graph (the alarm references the topic ARN, the metric filter references the log-group name, and Terraform orders all of it), a plan that shows the exact diff before anything changes, and a module you apply to fifty instances by changing one for_each.

Why Terraform rather than the console, the AWS CLI, or CloudFormation? Because monitoring is precisely the kind of sprawling, repetitive, cross-referencing configuration that rewards code and punishes clicking. A fleet needs the same CPU alarm on every host; a console gives you fifty subtly different ones. An audit needs to know who changed a threshold and when; git has the answer, the console does not. CloudFormation can express all of this too, but you lose Terraform’s plan preview, its for_each ergonomics over a list of services, and the single state model that already holds the EC2, ALB and RDS resources these alarms point at.

Terraform-built AWS observability plane read left to right — Terraform provisions the stack; EC2, an ALB and RDS emit metrics and logs into CloudWatch; a metric alarm evaluated over M-of-N datapoints (with a treat_missing_data policy) or a metric filter promoted from a log pattern fires alarm_actions to an SNS topic, which fans out to email and Slack or invokes a Lambda for auto-remediation, while the log group's retention_in_days caps the bill

Reading that diagram left to right is reading the signal path you are about to build: your resources emit metrics and logs into CloudWatch; a metric alarm (or a metric filter promoted from a log line) evaluates the numbers and, when the breach survives the M-of-N test, fires its alarm_actions at an SNS topic; the topic fans the message out to humans (email, Slack) or to a Lambda that auto-remediates. Two things sit off to the side but matter as much as the happy path: treat_missing_data, which decides what an alarm does when the metric goes silent, and retention_in_days, which is the difference between a predictable log bill and a runaway one.

Here is the full inventory a single terraform apply creates in the hands-on, and roughly what each part costs if you leave it running (ap-south-1 / Mumbai, on-demand, indicative July 2026):

Resource (Terraform) AWS object Role in the build Rough cost if left up
aws_instance (t3.micro) EC2 instance The thing being watched (CPU alarm target) ~₹625/mo (~$7.5)
aws_cloudwatch_log_group Log group /app/demo App logs, retention 14 days ~₹0 at demo volume
aws_cloudwatch_log_metric_filter Metric filter ERROR count → custom metric Free (metric billed)
aws_cloudwatch_metric_alarm ×2 CPU alarm + error alarm Fire on breach $0.10/alarm/mo
aws_cloudwatch_composite_alarm Composite Page only when both fire $0.50/mo
aws_sns_topic + _subscription SNS topic + email sub Notification fan-out Free at demo volume
aws_sns_topic_policy Topic access policy Let CloudWatch publish Free
aws_cloudwatch_dashboard Dashboard One-pane overview Free (first 3)
aws_iam_role + aws_lambda_function (opt.) Remediation Lambda Auto-fix path ~₹0 (idle)

The EC2 instance is the only line item with a real hourly charge; everything CloudWatch and SNS here is effectively free at demo volume. It is still a build it, verify it, destroy it lesson — an instance left running for a month is a bill you did not mean to pay — and every costly or destructive step below is marked ⚠️.

Where this fits: the CPU alarm here targets a single instance so the lesson stays about observability, but in production you alarm on an Auto Scaling group (AutoScalingGroupName dimension) and let a scaling policy react — that compute layer is the subject of the AWS Auto Scaling & launch templates lesson. The ALB and RDS this plane watches are stood up properly in the 3-tier architecture, modules & SRE remote-state lesson, which is also where the S3 + DynamoDB backend these alarms live beside comes from. And when you outgrow raw CloudWatch alarms into SLOs, error budgets and synthetic browser checks, the Datadog monitors, SLOs & synthetics lesson picks up exactly there.

Why observability as code (and not click-ops)

Before the resources, the argument — because it governs every choice below. Monitoring drifts faster than almost any other infrastructure: someone bumps a threshold to stop a page, someone adds an alarm for one incident and never removes it, someone forgets the new service entirely. Code fixes each of those failure modes structurally, not by discipline.

Concern Click-ops (console) Observability as code (Terraform)
Consistency across a fleet Fifty hand-made alarms, all slightly different One module, one threshold, for_each over the fleet
Change history None — the console has no diff Every threshold change is a reviewed git commit
Reproducibility (dev = prod) Re-click it and hope terraform apply with a different .tfvars
Coverage of new services Manual, forgotten under load New service in the list → alarms appear automatically
Rollback Screenshot and pray git revert + apply
Ownership / review Whoever clicked last CODEOWNERS on the .tf, PR review
Deletion of stale alarms Nobody dares plan shows the destroy explicitly
Cost control (retention, dashboards) Invisible until the bill retention_in_days and dashboard count are in code

The SRE reframing is the important one: an alarm is a contract about what “healthy” means for a service, and a contract belongs in version control where it can be reviewed, tested and evolved — not in a UI where it can be silently edited. Everything that follows treats CloudWatch and SNS as declarative infrastructure, exactly like a VPC or an IAM role.

Metrics and alarms: aws_cloudwatch_metric_alarm

A CloudWatch alarm watches a single metric (or a metric-math expression) and moves between three states — OK, ALARM, and INSUFFICIENT_DATA — based on how the metric compares to a threshold over a window of time. The whole art is in the window: a good alarm fires on a real, sustained problem and stays quiet through a one-second blip. Here is the canonical resource — a CPU alarm on an EC2 instance:

resource "aws_cloudwatch_metric_alarm" "ec2_cpu_high" {
  alarm_name          = "${var.project}-ec2-cpu-high"
  alarm_description   = "EC2 CPUUtilization >= 80% for 3 of the last 5 minutes"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  statistic           = "Average"
  period              = 60          # seconds per datapoint
  evaluation_periods  = 5           # look back over 5 datapoints
  datapoints_to_alarm = 3           # fire if 3 of those 5 breach (M-of-N)
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 80
  treat_missing_data  = "missing"
  dimensions          = { InstanceId = aws_instance.app.id }

  alarm_actions             = [aws_sns_topic.alerts.arn]
  ok_actions                = [aws_sns_topic.alerts.arn]
  insufficient_data_actions = []
  tags                      = local.tags
}

Read it as a sentence: average CPU, sampled every 60 seconds, alarms if it is at or above 80% in at least 3 of the last 5 samples, and when it does it publishes to the SNS topic. Every one of those words maps to an argument, and getting them right is the difference between a trustworthy alarm and pager fatigue.

Argument What it controls Notes / gotcha
alarm_name Unique name in the account/region Must be unique; used by composite alarm_rule
namespace Metric namespace AWS/EC2, AWS/ApplicationELB, AWS/RDS, custom Custom/*
metric_name The metric e.g. CPUUtilization, HTTPCode_Target_5XX_Count
statistic Aggregation Average, Sum, Minimum, Maximum, SampleCount
extended_statistic Percentile e.g. p99; mutually exclusive with statistic
period Seconds per datapoint 10/30 = high-resolution (costs more); else multiple of 60
evaluation_periods How many datapoints in the window The N in “M of N”
datapoints_to_alarm How many must breach The M; omit ⇒ M = N (all must breach)
comparison_operator Direction of the test see table below
threshold The number compared against omit when using threshold_metric_id (anomaly)
treat_missing_data Behaviour when metric is silent missing/notBreaching/breaching/ignore
dimensions Which resource wrong dimension ⇒ alarm watches nothing
alarm_actions ARNs to notify on ALARM SNS topic / Auto Scaling / OpsItem / EC2 action
ok_actions ARNs to notify on recovery send the “resolved” too, or Slack shows only fires
insufficient_data_actions ARNs on INSUFFICIENT_DATA usually empty to avoid noise
unit Constrain the metric unit leave unset unless a metric is multi-unit
actions_enabled Master switch for actions false = evaluate but stay silent (staging)
metric_query Metric-math / anomaly blocks replaces the flat metric args
threshold_metric_id ID of the anomaly band pairs with GreaterThanUpperThreshold

The comparison operator picks the direction and, for anomaly detection, the band form:

comparison_operator Fires when metric is… Typical use
GreaterThanOrEqualToThreshold >= threshold CPU, latency, error count
GreaterThanThreshold > threshold strictly-above rates
LessThanThreshold < threshold healthy-host count too low
LessThanOrEqualToThreshold <= threshold throughput floor
LessThanLowerOrGreaterThanUpperThreshold outside anomaly band (either side) anomaly detection
GreaterThanUpperThreshold above anomaly band one-sided anomaly (spikes)
LessThanLowerThreshold below anomaly band one-sided anomaly (drops)

statistic vs extended_statistic trips people up: the flat statistics are cheap aggregates; percentiles need the extended argument, and you cannot set both:

Aggregation Argument Meaning Good for
Average statistic mean over the period CPU, memory
Sum statistic total over the period counts (5xx, requests)
Maximum / Minimum statistic extremes queue depth peaks
SampleCount statistic number of samples traffic presence
p90 / p95 / p99 extended_statistic percentile latency latency SLOs

evaluation_periods + datapoints_to_alarm is the single most important tuning knob — the “M of N” rule that decides how twitchy the alarm is. period × evaluation_periods is the window; datapoints_to_alarm is how much of that window must breach:

period evaluation_periods datapoints_to_alarm Behaviour
60s 1 1 Fires on a single bad minute — noisy
60s 5 5 Fires only if all 5 minutes breach — slow, misses flapping
60s 5 3 3 of 5 — sustained breach, tolerates one blip (recommended)
60s 3 2 Fast page for a paging alarm
300s 3 3 15-minute sustained — good for cost/slow metrics

treat_missing_data is the argument that produces the most “why is my alarm stuck on INSUFFICIENT_DATA?” tickets. A metric can simply stop arriving — the instance was replaced, the ALB got no traffic, the Lambda wasn’t invoked — and this setting decides what the alarm does with the gap:

Value Missing datapoints treated as… Use when
missing (default) keep current state; go INSUFFICIENT_DATA if the whole window is empty you genuinely don’t know
notBreaching good (as if below threshold) absence is fine (bursty/idle workloads)
breaching bad (as if above threshold) absence is the failure (heartbeat/liveness)
ignore don’t change state at all avoid flapping on sparse metrics

The rule of thumb: for a liveness signal (“this thing should always be emitting”), breaching — silence is an outage. For a bursty metric that legitimately goes quiet (an ALB with no night traffic), notBreaching — silence is fine. Leaving it default missing is why so many alarms sit forever in INSUFFICIENT_DATA and never page.

The alarm catalog: which metric per service

The other half of “a good alarm” is choosing the right metric and dimension per service. Get the dimension wrong and the alarm watches nothing (it stays INSUFFICIENT_DATA forever). This is the table to keep next to you when you write alarms — the metric, its namespace, the statistic, a sane starting threshold, and the dimension that scopes it:

Service Namespace Metric Stat Starter threshold Key dimension
EC2 instance AWS/EC2 CPUUtilization Average ≥ 80% (3/5) InstanceId
EC2 instance AWS/EC2 StatusCheckFailed Maximum ≥ 1 InstanceId
Auto Scaling group AWS/EC2 CPUUtilization (aggregated) Average ≥ 70% AutoScalingGroupName
ASG AWS/AutoScaling GroupInServiceInstances Average < desired AutoScalingGroupName
ALB AWS/ApplicationELB HTTPCode_Target_5XX_Count Sum ≥ 5 / min LoadBalancer
ALB AWS/ApplicationELB TargetResponseTime p99 ≥ 1.0 s LoadBalancer (+ TargetGroup)
ALB AWS/ApplicationELB UnHealthyHostCount Maximum ≥ 1 TargetGroup, LoadBalancer
ALB AWS/ApplicationELB RejectedConnectionCount Sum ≥ 1 LoadBalancer
RDS AWS/RDS CPUUtilization Average ≥ 80% DBInstanceIdentifier
RDS AWS/RDS DatabaseConnections Average ≥ 80% of max DBInstanceIdentifier
RDS AWS/RDS FreeStorageSpace Average < 10 GB DBInstanceIdentifier
RDS AWS/RDS FreeableMemory Average < 256 MB DBInstanceIdentifier
RDS AWS/RDS ReadLatency / WriteLatency Average ≥ 20 ms DBInstanceIdentifier
Lambda AWS/Lambda Errors Sum ≥ 1 FunctionName
Lambda AWS/Lambda Throttles Sum ≥ 1 FunctionName
Lambda AWS/Lambda Duration p95 near timeout FunctionName
SQS AWS/SQS ApproximateAgeOfOldestMessage Maximum ≥ 300 s QueueName
SQS AWS/SQS ApproximateNumberOfMessagesVisible Average backlog SLO QueueName

Two dimension gotchas worth their own line. First, ALB and Target Group dimensions want the arn_suffix, not the ARN — in Terraform that is aws_lb.this.arn_suffix and aws_lb_target_group.this.arn_suffix. Pass the full ARN and the alarm silently watches nothing. Second, ASG-aggregated CPU lives in AWS/EC2 keyed by AutoScalingGroupName (a roll-up across the group), while the group’s own metrics (GroupInServiceInstances, GroupDesiredCapacity) live in AWS/AutoScaling — and the latter must be explicitly enabled on the ASG (enabled_metrics) before they exist to alarm on.

Composite alarms, metric math and anomaly detection

Three techniques turn a pile of single-metric alarms into something an SRE actually trusts.

Composite alarms (aws_cloudwatch_composite_alarm) combine other alarms with boolean logic so you page on user impact, not on any single twitchy signal. The classic use: only page when CPU is high and 5xx is elevated — high CPU alone might just be a batch job; high CPU plus errors is a real incident. The composite has no metric of its own; it references other alarms by name in an alarm_rule:

resource "aws_cloudwatch_composite_alarm" "service_degraded" {
  alarm_name        = "${var.project}-service-degraded"
  alarm_description = "Page only when CPU high AND 5xx elevated (real user impact)"

  alarm_rule = join(" AND ", [
    "ALARM(${aws_cloudwatch_metric_alarm.ec2_cpu_high.alarm_name})",
    "ALARM(${aws_cloudwatch_metric_alarm.alb_5xx_rate.alarm_name})",
  ])

  alarm_actions   = [aws_sns_topic.pager.arn]   # this one pages
  ok_actions      = [aws_sns_topic.pager.arn]
  actions_enabled = true

  depends_on = [
    aws_cloudwatch_metric_alarm.ec2_cpu_high,
    aws_cloudwatch_metric_alarm.alb_5xx_rate,
  ]
}

The child alarms keep their (quieter) email actions; only the composite talks to the pager. That is how you cut alarm noise without going blind. The alarm_rule grammar is small:

Token Meaning
ALARM("name") true when that alarm is in ALARM
OK("name") true when in OK
INSUFFICIENT_DATA("name") true when in INSUFFICIENT_DATA
AND / OR / NOT boolean combinators
TRUE / FALSE constants (useful to temporarily pin)
( ) grouping

Metric math lets an alarm compute a value that no single metric gives you — the most common being an error rate (5xx ÷ requests × 100) instead of a raw count, so a traffic spike doesn’t false-alarm. You express it with metric_query blocks: the raw metrics carry return_data = false (they feed the expression), and the expression carries return_data = true (it is what the alarm watches):

resource "aws_cloudwatch_metric_alarm" "alb_5xx_rate" {
  alarm_name          = "${var.project}-alb-5xx-rate"
  alarm_description   = "Target 5xx error rate > 5% for 2 of 3 minutes"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  datapoints_to_alarm = 2
  threshold           = 5            # percent
  treat_missing_data  = "notBreaching"
  alarm_actions       = [aws_sns_topic.alerts.arn]

  metric_query {
    id          = "e1"
    expression  = "(m_5xx / m_req) * 100"
    label       = "5xx error rate %"
    return_data = true
  }
  metric_query {
    id          = "m_5xx"
    return_data = false
    metric {
      metric_name = "HTTPCode_Target_5XX_Count"
      namespace   = "AWS/ApplicationELB"
      period      = 60
      stat        = "Sum"
      dimensions  = { LoadBalancer = aws_lb.this.arn_suffix }
    }
  }
  metric_query {
    id          = "m_req"
    return_data = false
    metric {
      metric_name = "RequestCount"
      namespace   = "AWS/ApplicationELB"
      period      = 60
      stat        = "Sum"
      dimensions  = { LoadBalancer = aws_lb.this.arn_suffix }
    }
  }
}
Metric-math function Does Example
arithmetic + - * / element-wise math (m1/m2)*100
RATE(m1) per-second rate of change throughput
SUM([m1,m2]) sum across metrics total across services
AVG / MIN / MAX aggregate an array fleet average
FILL(m1, value) fill gaps smooth sparse data
ANOMALY_DETECTION_BAND(m1, n) expected band ± n stddev anomaly alarms
IF(cond, a, b) conditional mask maintenance windows

Anomaly detection replaces a hand-picked threshold with a model of the metric’s normal band. You point the alarm at an ANOMALY_DETECTION_BAND expression and set threshold_metric_id (not threshold) plus a band-aware comparison operator:

resource "aws_cloudwatch_metric_alarm" "cpu_anomaly" {
  alarm_name          = "${var.project}-ec2-cpu-anomaly"
  alarm_description   = "EC2 CPU outside its expected band (2 stddev)"
  comparison_operator = "GreaterThanUpperThreshold"   # spikes only
  evaluation_periods  = 3
  datapoints_to_alarm = 2
  threshold_metric_id = "e1"
  treat_missing_data  = "missing"
  alarm_actions       = [aws_sns_topic.alerts.arn]

  metric_query {
    id          = "e1"
    expression  = "ANOMALY_DETECTION_BAND(m1, 2)"
    label       = "CPU (expected band)"
    return_data = true
  }
  metric_query {
    id          = "m1"
    return_data = true
    metric {
      metric_name = "CPUUtilization"
      namespace   = "AWS/EC2"
      period      = 300
      stat        = "Average"
      dimensions  = { InstanceId = aws_instance.app.id }
    }
  }
}
Static threshold Anomaly detection
You choose the number the band width (stddev)
Handles daily/weekly seasonality no yes (learned)
Good for hard SLOs (latency ≤ 1 s) metrics with no fixed “bad” value
Warm-up none needs history to learn the band
Cost alarm only alarm + anomaly-detector metrics
Risk wrong static number over/under-sensitive band

Use static thresholds for anything with a real SLO (a latency budget, an error-rate ceiling) and anomaly detection for shape-shifting metrics — request volume, queue depth — where “normal” is a moving target and no fixed number is ever right.

SNS alerting: topics, subscriptions and alarm_actions

An alarm on its own only changes colour in a console. The alarm_actions list is what turns a state change into a notification, and the near-universal target is an SNS topic — a pub/sub fan-out that lets one firing alarm reach many places at once. You create the topic, subscribe endpoints to it, and put its ARN in the alarm’s alarm_actions:

resource "aws_sns_topic" "alerts" {
  name = "${var.project}-alerts"
  tags = local.tags
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = var.alarm_email        # confirmed OUT OF BAND — see below
}
aws_sns_topic argument Purpose Note
name Topic name name_prefix for generated names
fifo_topic Ordered, deduped topic name must end .fifo; not for alarms
kms_master_key_id Encrypt at rest ⚠️ CloudWatch can’t publish to an AWS-managed key — see gotchas
delivery_policy Retry/backoff for HTTP(S) JSON policy
signature_version Message signing (1 or 2) 2 for stronger verification
tracing_config X-Ray active tracing for traced pipelines

Subscriptions are where the fan-out happens — the same topic can notify a human inbox, a phone, a webhook, an SQS queue for buffering, or a Lambda for action. The protocol decides both the endpoint format and, crucially, whether Terraform can finish the job:

Protocol Endpoint Confirmation Typical use
email address manual (click link in email) humans; stays PendingConfirmation until clicked
email-json address manual humans wanting raw JSON
sms phone number none on-call SMS (per-message cost)
https / http webhook URL auto (endpoint must echo token) Slack via incoming webhook, PagerDuty
lambda function ARN auto auto-remediation / custom routing
sqs queue ARN auto buffer, replay, decouple
application platform-endpoint ARN auto mobile push
firehose delivery-stream ARN (+ role) auto archive to S3

The single biggest SNS surprise for Terraform users: an email subscription cannot be confirmed by Terraform. apply creates the subscription in PendingConfirmation state and AWS sends a confirmation email; until a human clicks the link, no alarm notification is delivered. This is by design (anti-spam) and there is no argument to bypass it — you confirm out of band, once. For https/http/lambda/sqs, confirmation is automatic and the wiring is complete on apply.

Slack and PagerDuty ride on top of these protocols. The clean, code-only path to Slack is AWS Chatbot (aws_chatbot_slack_channel_configuration) subscribed to the topic — no webhook secret in state; alarms render as rich cards in a channel. The alternative is an https subscription pointed at a Slack Incoming Webhook, or at PagerDuty’s SNS integration URL, both of which auto-confirm. For anything that needs to reshape the message (SNS’s JSON is ugly in Slack), subscribe a small Lambda and let it format and forward.

Topic policy is the gotcha that produces “the alarm fires but nothing arrives.” An SNS topic’s default access policy allows the owning account’s principals, but a CloudWatch alarm publishes as the cloudwatch.amazonaws.com service principal — and if you have tightened the topic policy at all, you must explicitly allow it (with a SourceArn condition so only your alarms can publish):

data "aws_caller_identity" "me" {}

data "aws_iam_policy_document" "alerts_policy" {
  statement {
    sid       = "AllowCloudWatchAlarmsToPublish"
    effect    = "Allow"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.alerts.arn]
    principals {
      type        = "Service"
      identifiers = ["cloudwatch.amazonaws.com"]
    }
    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudwatch:${var.region}:${data.aws_caller_identity.me.account_id}:alarm:*"]
    }
  }
  # EventBridge → SNS needs events.amazonaws.com the same way
  statement {
    sid       = "AllowEventBridgeToPublish"
    effect    = "Allow"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.alerts.arn]
    principals {
      type        = "Service"
      identifiers = ["events.amazonaws.com"]
    }
  }
}

resource "aws_sns_topic_policy" "alerts" {
  arn    = aws_sns_topic.alerts.arn
  policy = data.aws_iam_policy_document.alerts_policy.json
}

Auto-remediation is the payoff of a Lambda subscription: instead of paging a human, the alarm’s SNS message triggers code that fixes the problem — reboot a wedged instance, bump ASG desired capacity, clear a queue, flush a cache. You subscribe the function and grant SNS permission to invoke it:

resource "aws_sns_topic_subscription" "remediate" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.remediate.arn
}

resource "aws_lambda_permission" "allow_sns" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.remediate.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.alerts.arn
}

Forget the aws_lambda_permission and the subscription exists but SNS is denied at invoke time — the function never runs and, worse, the failure is silent unless you also alarm on SNS NumberOfNotificationsFailed.

Logs: log groups, retention, metric filters and shipping

Metrics tell you that something is wrong; logs tell you what. CloudWatch Logs organises log streams into log groups, and the single most important argument on a log group is the one Terraform makes you set on purpose: retention_in_days.

resource "aws_cloudwatch_log_group" "app" {
  name              = "/app/${var.project}"
  retention_in_days = 14           # ⚠️ omit this and logs are kept FOREVER
  kms_key_id        = var.logs_kms_key_arn   # optional CMK encryption
  tags              = local.tags
}
aws_cloudwatch_log_group argument Purpose Note
name / name_prefix Group name app logs /app/*, Lambda /aws/lambda/<fn>
retention_in_days How long to keep logs default = never expire = unbounded bill
kms_key_id Encrypt with a CMK key policy must allow logs.<region>.amazonaws.com
log_group_class STANDARD or INFREQUENT_ACCESS IA ≈ half the ingestion price, fewer features
skip_destroy Keep logs on terraform destroy for audit logs you must not lose

The default behaviour — retain logs forever — is the most common silent AWS cost leak there is: a chatty app ships gigabytes a day into a log group nobody set retention on, and years later it is a five-figure storage line. Always set retention_in_days to a real value. The allowed values are a fixed set; here is the cost intuition and the common choices:

retention_in_days Keeps Fits
1, 3, 5 days high-volume debug logs
7, 14 1–2 weeks app logs, dev/staging
30, 60, 90 1–3 months prod app logs
180, 365 6–12 months security-relevant logs
400, 545, 731 ~1–2 years compliance
1827, 3653 5 / 10 years long-retention audit
0 / unset forever almost never what you want

CloudWatch Logs bills on ingestion (roughly $0.50/GB, or ~half that for the Infrequent Access class), storage (~$0.03/GB-month), and analysis (Logs Insights queries scan-billed). Retention caps the storage term; the log-group class caps the ingestion term. Both are one-line decisions in Terraform and invisible in the console until the bill.

Metric filters are the bridge back to alarming: they scan a log group for a pattern and emit a custom metric every time it matches — so you can alarm on “count of ERROR lines” exactly like a native metric. This is how you page on a log signal (a stack trace, an OOM, a specific error string) that has no metric of its own:

resource "aws_cloudwatch_log_metric_filter" "app_errors" {
  name           = "${var.project}-app-errors"
  log_group_name = aws_cloudwatch_log_group.app.name
  pattern        = "?ERROR ?Exception ?\"Traceback\""   # OR of terms

  metric_transformation {
    name          = "AppErrorCount"
    namespace     = "Custom/${var.project}"
    value         = "1"
    default_value = "0"       # emit 0 when no match → alarm can leave INSUFFICIENT_DATA
    unit          = "Count"
  }
}

resource "aws_cloudwatch_metric_alarm" "app_error_rate" {
  alarm_name          = "${var.project}-app-errors"
  alarm_description   = ">= 5 ERROR log lines per minute"
  namespace           = "Custom/${var.project}"
  metric_name         = "AppErrorCount"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 1
  threshold           = 5
  comparison_operator = "GreaterThanOrEqualToThreshold"
  treat_missing_data  = "notBreaching"
  alarm_actions       = [aws_sns_topic.alerts.arn]
}

Two subtleties make or break this. Setting default_value = "0" means the filter emits a zero when nothing matches, so the metric is continuous and the alarm can sit in OK instead of INSUFFICIENT_DATA — without it, a quiet period looks like missing data. And the pattern grammar has two dialects:

Pattern style Example Matches
Plain term ERROR lines containing ERROR
OR of terms ?ERROR ?WARN lines containing either
Exact/quoted "Out of memory" the exact phrase
Exclude -INFO lines not containing INFO
Space-delimited fields [ip, id, user, ..., status=5*] 5xx in a positional log
JSON selector { $.level = "ERROR" } structured JSON logs
JSON numeric { $.latency > 1000 } numeric field threshold

For structured JSON logs the { $.field = "x" } form is far more robust than string matching — it survives log-format changes and lets you threshold numeric fields directly.

Subscription filters (aws_cloudwatch_log_subscription_filter) are the other direction — they stream matching log events out of CloudWatch in near-real-time to another service for processing or centralisation:

Destination Resource wiring Use
Lambda destination_arn + aws_lambda_permission transform, forward, alert
Kinesis Data Streams destination_arn + role_arn high-throughput fan-in
Kinesis Data Firehose destination_arn + role_arn archive to S3 / OpenSearch
Cross-account log destination destination_arn (Logs Destination) central logging account

Getting logs into the group is the last mile. Lambda writes to /aws/lambda/<fn> automatically. For EC2 and on-host app logs you run the CloudWatch agent, whose config (usually stored in SSM Parameter Store and pulled at boot) both ships log files and collects the metrics EC2 does not emit by default — memory and disk:

resource "aws_ssm_parameter" "cw_agent" {
  name = "/${var.project}/cloudwatch-agent/config"
  type = "String"
  value = jsonencode({
    agent   = { metrics_collection_interval = 60 }
    metrics = { namespace = "CWAgent", metrics_collected = {
      mem  = { measurement = ["mem_used_percent"] }
      disk = { measurement = ["used_percent"], resources = ["*"] }
    } }
    logs = { logs_collected = { files = { collect_list = [{
      file_path         = "/var/log/app/app.log"
      log_group_name    = aws_cloudwatch_log_group.app.name
      log_stream_name   = "{instance_id}"
      retention_in_days = 14
    }] } } }
  })
}
Signal Emitted by default? How to get it
EC2 CPU, network, disk I/O yes (hypervisor) native AWS/EC2
EC2 memory used % no CloudWatch agent → CWAgent
EC2 disk used % (filesystem) no CloudWatch agent → CWAgent
App log files no CloudWatch agent logs.collect_list
Lambda logs yes automatic /aws/lambda/*

The memory/disk gap catches everyone: teams alarm on CPU, an instance runs out of memory, and there was never a metric because the agent was never installed.

Dashboards as code

A dashboard is the one artifact the team looks at without being paged, and aws_cloudwatch_dashboard renders it from a JSON body — which jsonencode lets you write as native HCL instead of a brittle string. Each widget is an object with a type, a position on a 24-column grid, and properties:

resource "aws_cloudwatch_dashboard" "overview" {
  dashboard_name = "${var.project}-overview"
  dashboard_body = jsonencode({
    widgets = [
      {
        type = "metric", x = 0, y = 0, width = 12, height = 6,
        properties = {
          title   = "EC2 CPU"
          region  = var.region
          view    = "timeSeries"
          metrics = [["AWS/EC2", "CPUUtilization", "InstanceId", aws_instance.app.id]]
          yAxis   = { left = { min = 0, max = 100 } }
          annotations = { horizontal = [{ label = "alarm", value = 80 }] }
        }
      },
      {
        type = "log", x = 12, y = 0, width = 12, height = 6,
        properties = {
          title  = "Recent errors"
          region = var.region
          query  = "SOURCE '${aws_cloudwatch_log_group.app.name}' | fields @timestamp, @message | filter @message like /ERROR/ | sort @timestamp desc | limit 20"
        }
      },
      {
        type = "alarm", x = 0, y = 6, width = 24, height = 3,
        properties = {
          title  = "Alarm status"
          alarms = [aws_cloudwatch_metric_alarm.ec2_cpu_high.arn]
        }
      }
    ]
  })
}
Widget type Shows Key property
metric time-series / stacked / number metrics, view, stat, period
log Logs Insights results query
alarm alarm status grid alarms (ARNs)
text Markdown notes markdown
number / gauge single value / gauge view = "singleValue" / "gauge"

For a dashboard reused across environments, move the body into a templatefile("dashboard.json.tpl", { instance_id = ..., region = ... }) so the same layout renders with per-environment IDs — the file stays valid JSON you can lint, and Terraform injects the values. CloudWatch bills $3/dashboard-month after the first three, so generate a few meaningful dashboards, not one per resource.

EventBridge for event-driven ops

Alarms watch metrics; EventBridge (aws_cloudwatch_event_rule + aws_cloudwatch_event_target) watches events — an instance changing state, an ECS task dying, a scheduled tick — and routes them to a target. It is the event-driven complement to metric alarms, and the same SNS topic is a natural target:

resource "aws_cloudwatch_event_rule" "ec2_stopped" {
  name        = "${var.project}-ec2-stopped"
  description = "Notify when an instance stops or terminates"
  event_pattern = jsonencode({
    source        = ["aws.ec2"]
    "detail-type" = ["EC2 Instance State-change Notification"]
    detail        = { state = ["stopped", "terminated"] }
  })
}

resource "aws_cloudwatch_event_target" "to_sns" {
  rule      = aws_cloudwatch_event_rule.ec2_stopped.name
  target_id = "notify-sns"
  arn       = aws_sns_topic.alerts.arn
}
Rule trigger Argument Example
Event match event_pattern (JSON) EC2 state change, ECS task stopped, GuardDuty finding
Schedule schedule_expression rate(5 minutes), cron(0 3 * * ? *)
Targets aws_cloudwatch_event_target SNS, Lambda, SQS, Step Functions, ECS task

The same “let the service publish” rule applies: an EventBridge → SNS target needs the topic policy to allow events.amazonaws.com (shown earlier); an EventBridge → Lambda target needs an aws_lambda_permission for events.amazonaws.com. For pure cron use schedule_expression; note that AWS now steers new scheduling toward EventBridge Scheduler (aws_scheduler_schedule), but rules remain the right tool for event patterns.

SLOs and synthetics are the layer above all of this — turning these raw signals into error budgets and running scripted browser/API checks from outside your infrastructure. CloudWatch has Synthetics canaries and Application Signals for that; when you want first-class SLOs, burn-rate alerts and multi-step browser tests as code, the Datadog monitors, SLOs & synthetics lesson covers the same ideas with a dedicated observability provider.

Hands-on: build it with Terraform

Now the centrepiece — a complete, self-contained stack you run end to end: an EC2 instance, a retained log group with a metric filter, a CPU alarm and an error-count alarm both wired to an SNS email topic, a composite alarm, and a dashboard. You will apply, confirm the email, deliberately trigger the alarm to watch the notification land, then destroy. ⚠️ This creates a billable EC2 instance — do the whole loop in one sitting.

Step 0 — layout. Create a directory with these files:

cw-observability/
├── versions.tf
├── variables.tf
├── main.tf
├── outputs.tf
└── terraform.tfvars

Step 1 — versions.tf (provider pin + remote backend). The S3 bucket and DynamoDB lock table are assumed to exist (they come from the 3-tier / remote-state lesson); for a throwaway run you can delete the backend block and use local state.

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
  backend "s3" {
    bucket         = "kloudvin-tfstate-apsouth1"
    key            = "labs/cw-observability/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "kloudvin-tf-locks"
    encrypt        = true
  }
}

Step 2 — variables.tf.

variable "region" {
  type    = string
  default = "ap-south-1"
}
variable "project" {
  type    = string
  default = "cwlab"
}
variable "alarm_email" {
  type        = string
  description = "Address that must confirm the SNS subscription"
}
variable "instance_type" {
  type    = string
  default = "t3.micro"
}

Step 3 — main.tf (the whole plane). It uses the latest Amazon Linux 2023 AMI, a locals block for tags, and wires every resource discussed above.

provider "aws" {
  region = var.region
  default_tags { tags = local.tags }
}

locals {
  tags = {
    Project   = var.project
    ManagedBy = "Terraform"
    Lesson    = "cloudwatch-sns-observability"
  }
}

data "aws_caller_identity" "me" {}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# --- The thing we watch ------------------------------------------------------
resource "aws_instance" "app" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.instance_type
  tags          = { Name = "${var.project}-app" }
}

# --- SNS: topic + email subscription + policy --------------------------------
resource "aws_sns_topic" "alerts" {
  name = "${var.project}-alerts"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = var.alarm_email
}

data "aws_iam_policy_document" "alerts_policy" {
  statement {
    sid       = "AllowCloudWatchAlarmsToPublish"
    effect    = "Allow"
    actions   = ["SNS:Publish"]
    resources = [aws_sns_topic.alerts.arn]
    principals {
      type        = "Service"
      identifiers = ["cloudwatch.amazonaws.com"]
    }
    condition {
      test     = "ArnLike"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudwatch:${var.region}:${data.aws_caller_identity.me.account_id}:alarm:*"]
    }
  }
}

resource "aws_sns_topic_policy" "alerts" {
  arn    = aws_sns_topic.alerts.arn
  policy = data.aws_iam_policy_document.alerts_policy.json
}

# --- Logs: retained group + metric filter ------------------------------------
resource "aws_cloudwatch_log_group" "app" {
  name              = "/app/${var.project}"
  retention_in_days = 14
}

resource "aws_cloudwatch_log_metric_filter" "app_errors" {
  name           = "${var.project}-app-errors"
  log_group_name = aws_cloudwatch_log_group.app.name
  pattern        = "?ERROR ?Exception"
  metric_transformation {
    name          = "AppErrorCount"
    namespace     = "Custom/${var.project}"
    value         = "1"
    default_value = "0"
    unit          = "Count"
  }
}

# --- Alarms ------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "ec2_cpu_high" {
  alarm_name          = "${var.project}-ec2-cpu-high"
  alarm_description   = "EC2 CPU >= 80% for 3 of 5 minutes"
  namespace           = "AWS/EC2"
  metric_name         = "CPUUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 5
  datapoints_to_alarm = 3
  threshold           = 80
  comparison_operator = "GreaterThanOrEqualToThreshold"
  treat_missing_data  = "missing"
  dimensions          = { InstanceId = aws_instance.app.id }
  alarm_actions       = [aws_sns_topic.alerts.arn]
  ok_actions          = [aws_sns_topic.alerts.arn]
}

resource "aws_cloudwatch_metric_alarm" "app_error_rate" {
  alarm_name          = "${var.project}-app-errors"
  alarm_description   = ">= 5 ERROR log lines per minute"
  namespace           = "Custom/${var.project}"
  metric_name         = "AppErrorCount"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 1
  threshold           = 5
  comparison_operator = "GreaterThanOrEqualToThreshold"
  treat_missing_data  = "notBreaching"
  alarm_actions       = [aws_sns_topic.alerts.arn]
}

resource "aws_cloudwatch_composite_alarm" "degraded" {
  alarm_name        = "${var.project}-degraded"
  alarm_description = "CPU high AND errors elevated"
  alarm_rule = join(" AND ", [
    "ALARM(${aws_cloudwatch_metric_alarm.ec2_cpu_high.alarm_name})",
    "ALARM(${aws_cloudwatch_metric_alarm.app_error_rate.alarm_name})",
  ])
  alarm_actions = [aws_sns_topic.alerts.arn]
  depends_on    = [aws_cloudwatch_metric_alarm.ec2_cpu_high, aws_cloudwatch_metric_alarm.app_error_rate]
}

# --- Dashboard ---------------------------------------------------------------
resource "aws_cloudwatch_dashboard" "overview" {
  dashboard_name = "${var.project}-overview"
  dashboard_body = jsonencode({
    widgets = [
      { type = "metric", x = 0, y = 0, width = 12, height = 6, properties = {
        title = "EC2 CPU", region = var.region, view = "timeSeries",
        metrics = [["AWS/EC2", "CPUUtilization", "InstanceId", aws_instance.app.id]],
        yAxis = { left = { min = 0, max = 100 } },
        annotations = { horizontal = [{ label = "alarm", value = 80 }] } } },
      { type = "log", x = 12, y = 0, width = 12, height = 6, properties = {
        title = "Recent errors", region = var.region,
        query = "SOURCE '${aws_cloudwatch_log_group.app.name}' | fields @timestamp, @message | filter @message like /ERROR/ | sort @timestamp desc | limit 20" } }
    ]
  })
}

Step 4 — outputs.tf.

output "sns_topic_arn"   { value = aws_sns_topic.alerts.arn }
output "instance_id"     { value = aws_instance.app.id }
output "cpu_alarm_name"  { value = aws_cloudwatch_metric_alarm.ec2_cpu_high.alarm_name }
output "log_group_name"  { value = aws_cloudwatch_log_group.app.name }
output "dashboard_url" {
  value = "https://${var.region}.console.aws.amazon.com/cloudwatch/home?region=${var.region}#dashboards:name=${aws_cloudwatch_dashboard.overview.dashboard_name}"
}

Step 5 — init and plan. Put your email in terraform.tfvars (alarm_email = "you@example.com"), then:

terraform init
terraform plan -out tf.plan

Representative tail of the plan:

Plan: 12 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + cpu_alarm_name  = "cwlab-ec2-cpu-high"
  + dashboard_url   = (known after apply)
  + instance_id     = (known after apply)
  + log_group_name  = "/app/cwlab"
  + sns_topic_arn   = (known after apply)

Step 6 — apply. ⚠️ Billable EC2 instance from here.

terraform apply tf.plan

Step 7 — confirm the email subscription (the step Terraform can’t do). Check your inbox for “AWS Notification - Subscription Confirmation” and click Confirm subscription. Verify it took:

aws sns list-subscriptions-by-topic \
  --topic-arn "$(terraform output -raw sns_topic_arn)" \
  --query 'Subscriptions[].SubscriptionArn' --output text
# a real ARN = confirmed;  "PendingConfirmation" = you haven't clicked yet

Step 8 — verify the alarms exist and their state.

aws cloudwatch describe-alarms \
  --alarm-names "$(terraform output -raw cpu_alarm_name)" \
  --query 'MetricAlarms[0].[AlarmName,StateValue,Threshold]' --output text
# cwlab-ec2-cpu-high  INSUFFICIENT_DATA  80.0   (new alarm, no datapoints yet)

Step 9 — trigger the alarm and watch the email arrive. The fastest, cost-free way to prove the wiring (alarm → SNS → inbox) is to force the state with set-alarm-state:

aws cloudwatch set-alarm-state \
  --alarm-name "$(terraform output -raw cpu_alarm_name)" \
  --state-value ALARM \
  --state-reason "Manual test of SNS wiring"

Within seconds you should receive an email titled “ALARM: cwlab-ec2-cpu-high”. Because ok_actions is also set, it will auto-recover on the next real datapoint and send an “OK:” email too. To prove the metric filter path, push a matching log line and let the alarm evaluate:

aws logs create-log-stream --log-group-name "$(terraform output -raw log_group_name)" --log-stream-name test
aws logs put-log-events --log-group-name "$(terraform output -raw log_group_name)" \
  --log-stream-name test \
  --log-events "timestamp=$(( $(date +%s) * 1000 )),message=ERROR something broke x6"
# repeat / send 5+ within a minute; the app_error_rate alarm flips to ALARM

For a real CPU alarm (not forced), SSH in and run stress-ng --cpu 2 --timeout 300s (or sudo dnf install -y stress-ng first) and watch the alarm flip after ~3 minutes on the dashboard.

Step 10 — see the dashboard. terraform output -raw dashboard_url and open it — CPU on the left, recent ERROR logs on the right.

Step 11 — destroy (do not skip). ⚠️

terraform destroy

Confirm with aws cloudwatch describe-alarms --alarm-name-prefix cwlab returning empty and the EC2 instance gone. The log group is destroyed too (no skip_destroy), so no lingering storage cost.

Variables, outputs and making it reusable

Copy-pasting that CPU alarm onto every instance is exactly the click-ops we set out to kill — just in HCL. The fix is a small module that takes a resource identity and a set of thresholds and emits a standard alarm set, so a new service gets consistent monitoring by adding one block. Structure:

modules/standard-alarms/
├── variables.tf
├── main.tf
└── outputs.tf

modules/standard-alarms/variables.tf:

variable "name"          { type = string }                 # alarm name prefix
variable "sns_topic_arn" { type = string }
variable "dimensions"    { type = map(string) }            # e.g. { InstanceId = "i-…" }
variable "namespace"     {
  type = string
  default = "AWS/EC2"
}
variable "cpu_threshold" {
  type = number
  default = 80
}
variable "extra_alarms" {                                  # optional, per-metric
  type = map(object({
    metric_name         = string
    statistic           = string
    threshold           = number
    comparison_operator = string
    period              = optional(number, 60)
    evaluation_periods  = optional(number, 5)
    datapoints_to_alarm = optional(number, 3)
    treat_missing_data  = optional(string, "missing")
  }))
  default = {}
}

modules/standard-alarms/main.tf — a baseline CPU alarm plus a for_each over any extra alarms the caller declares:

resource "aws_cloudwatch_metric_alarm" "cpu" {
  alarm_name          = "${var.name}-cpu-high"
  namespace           = var.namespace
  metric_name         = "CPUUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 5
  datapoints_to_alarm = 3
  threshold           = var.cpu_threshold
  comparison_operator = "GreaterThanOrEqualToThreshold"
  treat_missing_data  = "missing"
  dimensions          = var.dimensions
  alarm_actions       = [var.sns_topic_arn]
  ok_actions          = [var.sns_topic_arn]
}

resource "aws_cloudwatch_metric_alarm" "extra" {
  for_each            = var.extra_alarms
  alarm_name          = "${var.name}-${each.key}"
  namespace           = var.namespace
  metric_name         = each.value.metric_name
  statistic           = each.value.statistic
  period              = each.value.period
  evaluation_periods  = each.value.evaluation_periods
  datapoints_to_alarm = each.value.datapoints_to_alarm
  threshold           = each.value.threshold
  comparison_operator = each.value.comparison_operator
  treat_missing_data  = each.value.treat_missing_data
  dimensions          = var.dimensions
  alarm_actions       = [var.sns_topic_arn]
}

Now every service is one call — and stamping the same alarm set across a fleet is a for_each over your instances:

module "app_alarms" {
  source        = "./modules/standard-alarms"
  for_each      = toset(["i-0abc", "i-0def", "i-0ghi"])
  name          = "app-${each.key}"
  sns_topic_arn = aws_sns_topic.alerts.arn
  dimensions    = { InstanceId = each.key }
  extra_alarms = {
    status-check = { metric_name = "StatusCheckFailed", statistic = "Maximum",
                     threshold = 1, comparison_operator = "GreaterThanOrEqualToThreshold" }
  }
}
Module input Type Purpose
name string Alarm name prefix (per resource)
sns_topic_arn string Where alarms notify
dimensions map(string) Scopes every alarm to one resource
namespace string Service namespace
cpu_threshold number Baseline CPU alarm threshold
extra_alarms map(object) Any additional per-metric alarms

Roll your own vs the registry. The community terraform-aws-modules/cloudwatch/aws module ships submodules for metric alarms, log groups and metric filters and is worth using once your needs stabilise — it handles the argument surface and edge cases for you. Roll your own (as above) when you want an opinionated house standard — every service gets exactly these alarms, named this way, wired to this topic — which is often the more valuable thing for an SRE team than raw flexibility. The two compose: your standard module can call the registry module internally.

Common mistakes and troubleshooting

Monitoring has a cruel failure mode: when it is broken, it fails silently — the page that should have fired simply doesn’t. These are the ones that bite, symptom → cause → fix:

Symptom Cause Fix
Alarm stuck in INSUFFICIENT_DATA No datapoints (wrong dimension, or metric truly silent) Fix the dimension; set treat_missing_data; add default_value=0 on metric filters
Alarm never leaves OK on a real problem Dimension watches the wrong/empty resource Verify with get-metric-statistics; ALB/TG need arn_suffix, not ARN
Alarm fires but no email arrives Email subscription still PendingConfirmation Click the confirmation link; check list-subscriptions-by-topic
Alarm fires, SNS silent, topic is KMS-encrypted CloudWatch can’t use the AWS-managed key Use a customer-managed KMS key whose policy allows cloudwatch.amazonaws.com
Alarm fires, SNS silent, topic policy tightened Policy doesn’t allow cloudwatch.amazonaws.com Add the SNS:Publish statement with SourceArn condition
Lambda subscription never invoked Missing aws_lambda_permission for sns.amazonaws.com Add the permission with source_arn = topic ARN
Log bill climbing every month Log group has no retention_in_days (kept forever) Set retention; consider INFREQUENT_ACCESS class
Metric filter never produces datapoints Pattern doesn’t match, or no default_value Test with a known line; add default_value="0" for continuity
Percentile alarm errors on apply statistic and extended_statistic both set Use extended_statistic = "p99" alone for percentiles
Alarm flaps (fires and clears constantly) datapoints_to_alarm too low / period too short Raise evaluation_periods; use M-of-N (3 of 5)
Composite alarm error: alarm not found Child alarm created after composite Add depends_on on the child alarms
ALB latency alarm reads wrong numbers Averaging latency hides tail Alarm on p99 (extended_statistic), not Average
EC2 memory alarm has no metric Memory isn’t a default EC2 metric Install the CloudWatch agent (CWAgent namespace)
terraform destroy won’t remove log group skip_destroy = true set Remove skip_destroy or delete the group out of band

The three that cost the most hours, in prose. The KMS-encrypted topic is the sneakiest: everything looks correct, the alarm goes red, and no message arrives — because the alarm publishes as cloudwatch.amazonaws.com and the default SNS encryption key (alias/aws/sns) does not grant that service principal kms:GenerateDataKey. The fix is a customer-managed CMK whose key policy allows cloudwatch.amazonaws.com (and events.amazonaws.com if EventBridge publishes), then kms_master_key_id on the topic. The unconfirmed email is the most common: Terraform reports success, the subscription resource exists, but it sits in PendingConfirmation and delivers nothing until a human clicks — so a freshly-applied stack looks monitored but is deaf until someone confirms. And treat_missing_data is the quiet killer of liveness alarms: leave it default and an alarm that should scream when a heartbeat stops instead drifts into INSUFFICIENT_DATA and never pages — set it to breaching for anything whose absence is the failure.

Cost, cleanup and production notes

What it costs left running. The EC2 t3.micro is the only meaningful charge (~₹625/$7.5 a month). CloudWatch here is nearly free: standard alarms are $0.10 each per month, the composite is $0.50, custom metrics from the filter $0.30 each, and the first three dashboards are free. The real cost risks in production are two: log ingestion/storage on a chatty app with no retention (this is the one that produces surprise five-figure lines), and high-resolution alarms (period 10 or 30) at $0.30 each versus $0.10 — only pay for sub-minute resolution where you genuinely act on it.

Item Rough price (indicative) Control
Standard alarm $0.10 / alarm-month consolidate with composites
High-resolution alarm $0.30 / alarm-month use only where needed
Composite alarm $0.50 / alarm-month one per service, not per metric
Custom metric $0.30 / metric-month reuse dimensions, don’t explode cardinality
Dashboard $3 / month after first 3 a few good ones, not one per resource
Logs ingestion ~$0.50 / GB (½ for IA class) log level, sampling, IA class
Logs storage ~$0.03 / GB-month retention_in_days
SNS email first 1,000 free, then ~$2/100k fan-out, not per-alarm topics

Clean up with terraform destroy; the only thing that survives a destroy is a log group flagged skip_destroy (which you would only set for audit logs you must keep). Production hardening notes:

Cheat-sheet

Resources and the argument that matters most on each:

Resource Key arguments
aws_cloudwatch_metric_alarm namespace metric_name statistic/extended_statistic period evaluation_periods datapoints_to_alarm comparison_operator threshold treat_missing_data dimensions alarm_actions
aws_cloudwatch_metric_alarm (math/anomaly) metric_query { id expression/metric{} return_data } threshold_metric_id
aws_cloudwatch_composite_alarm alarm_rule (ALARM()/OK()/AND/OR/NOT) alarm_actions depends_on
aws_sns_topic name kms_master_key_id (⚠️ CMK for CW)
aws_sns_topic_subscription protocol endpoint (email = manual confirm)
aws_sns_topic_policy allow cloudwatch.amazonaws.com / events.amazonaws.com
aws_cloudwatch_log_group retention_in_days (⚠️ default = forever) kms_key_id log_group_class
aws_cloudwatch_log_metric_filter pattern metric_transformation{ name namespace value default_value }
aws_cloudwatch_log_subscription_filter filter_pattern destination_arn role_arn
aws_cloudwatch_dashboard dashboard_body = jsonencode({ widgets = [...] })
aws_cloudwatch_event_rule / _target event_pattern / schedule_expression; arn target

Verification and trigger commands:

Task Command
List alarms + state aws cloudwatch describe-alarms --alarm-name-prefix <p>
Force an alarm (test wiring) aws cloudwatch set-alarm-state --alarm-name <n> --state-value ALARM --state-reason test
Read a metric aws cloudwatch get-metric-statistics --namespace AWS/EC2 --metric-name CPUUtilization ...
Check subscription confirmed aws sns list-subscriptions-by-topic --topic-arn <arn>
Push a test log line aws logs put-log-events --log-group-name <g> --log-stream-name <s> --log-events ...
Tail logs live aws logs tail /app/<p> --follow

Alarm states: OK (metric within bounds), ALARM (threshold breached per M-of-N), INSUFFICIENT_DATA (not enough datapoints — governed by treat_missing_data).

Interview and exam questions

  1. What do evaluation_periods and datapoints_to_alarm do together? They define the “M of N” rule: evaluation_periods (N) is the window of datapoints CloudWatch inspects; datapoints_to_alarm (M) is how many of them must breach for the alarm to fire. 3 of 5 tolerates a one-datapoint blip while still catching a sustained problem. Omitting M makes it N-of-N.

  2. An alarm is stuck in INSUFFICIENT_DATA. Give two causes. Either no datapoints are arriving (wrong dimensions, so it’s watching a resource that emits nothing — e.g. an ALB alarm given the ARN instead of arn_suffix), or the metric is legitimately silent and treat_missing_data is left at missing. Fix the dimension, or set treat_missing_data to breaching/notBreaching per intent, and set default_value=0 on metric filters.

  3. Why might an alarm fire but no email arrive? Most often the email subscription is unconfirmed (PendingConfirmation — Terraform can’t confirm it). Otherwise the topic is KMS-encrypted with a key CloudWatch can’t use, or the topic policy doesn’t allow cloudwatch.amazonaws.com to SNS:Publish.

  4. When would you use a composite alarm? To page on correlated conditions and cut noise — e.g. only page when CPU is high AND 5xx is elevated (real user impact), leaving the individual alarms on quieter email actions. aws_cloudwatch_composite_alarm references child alarms by name in alarm_rule.

  5. How do you alarm on p99 latency? Use extended_statistic = "p99" (not statistic) on the TargetResponseTime metric — the two are mutually exclusive, and averaging latency hides the tail you actually care about.

  6. What’s the difference between a metric filter and a subscription filter? A metric filter counts log-pattern matches into a CloudWatch metric you can alarm on (log → metric → alarm). A subscription filter streams matching log events out to Lambda/Kinesis/Firehose in near-real-time for processing or centralisation (log → external pipeline).

  7. Why set retention_in_days? The default is never expire — logs accumulate and bill for storage forever. Setting retention caps storage cost; the INFREQUENT_ACCESS log-group class further cuts ingestion price.

  8. How does an alarm trigger auto-remediation? alarm_actions publishes to an SNS topic that has a Lambda subscription; the function runs the fix (reboot, scale, flush). You must add aws_lambda_permission allowing sns.amazonaws.com to invoke it, or the invoke is denied silently.

  9. Static threshold vs anomaly detection — when each? Static for hard SLOs where a fixed “bad” number exists (latency ≤ 1 s, error rate ≤ 1%). Anomaly detection for seasonal/shape-shifting metrics (request volume) where no fixed threshold is ever right; it needs history to learn the band and uses threshold_metric_id with a band comparison operator.

  10. (Terraform Associate style) You add an email subscription and apply succeeds, but notifications don’t arrive. Why? The subscription is created in PendingConfirmation; SNS email subscriptions require out-of-band confirmation that Terraform cannot perform. Notifications flow only after the recipient clicks the confirmation link.

  11. (Terraform Associate style) How do you emit the same alarm set across 30 instances without repeating HCL? Author a module that takes the resource identity + thresholds and emits the alarms, then call it with for_each over the instance IDs — one source of truth, consistent alarms, one place to change a threshold.

  12. Which EC2 signals are not available by default, and how do you get them? Memory and filesystem disk usage — the hypervisor doesn’t see them. Install the CloudWatch agent, which publishes them under the CWAgent namespace and can also ship app log files to a log group.

Key takeaways

TerraformawsCloudWatchSNSObservabilitySREMetric AlarmsComposite AlarmsMetric FiltersLog GroupsEventBridgeAnomaly DetectionIaC
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