Never Get Surprised by an AWS Bill: Setting Up a Free Tier Billing Alarm with CloudWatch

The AWS Free Tier is generous until it isn't — a forgotten EC2 instance, an unexpected data transfer spike, or a misconfigured Lambda can silently push you past the limit. A $5 billing alarm is the cheapest insurance policy you can set up in under 10 minutes.

TL;DR

StepActionKey Detail
1Enable Billing AlertsMust be done in us-east-1 (N. Virginia) — billing metrics only exist there
2Create SNS TopicActs as the notification dispatcher; email subscription requires manual confirmation
3Create CloudWatch AlarmMetric: EstimatedCharges, Namespace: AWS/Billing, Threshold: $5
4Confirm EmailSNS sends a confirmation link — alarm stays in INSUFFICIENT_DATA until confirmed

Core Fix: Billing metrics are only published to CloudWatch in us-east-1. All CLI commands below must target that region.

Why This Architecture Works: Data Flow Analysis

AWS billing data is aggregated globally and pushed as a CloudWatch metric (EstimatedCharges) into the AWS/Billing namespace — but exclusively in the us-east-1 region. This is a hard platform constraint, not a configuration choice. CloudWatch evaluates the metric against your defined threshold and, on breach, publishes a state-change event to an SNS topic. SNS then fans out that notification to all confirmed subscribers — in this case, your email address via the SMTP protocol.

Analogy: Think of CloudWatch as a security guard watching a single gauge on a control panel. SNS is the PA system the guard uses to broadcast an alert. The guard can only watch gauges in one specific room (us-east-1), regardless of where the actual activity is happening across the building.

Architecture Diagram

flowchart TD subgraph AWS_Global ["AWS Global Billing Engine"] A["All Region Usage\n(EC2, S3, Lambda...)"] -->|"Aggregated every ~6hrs"| B["EstimatedCharges Metric\nNamespace: AWS/Billing"] end subgraph us_east_1 ["Region: us-east-1 (Required)"] B --> C["CloudWatch Alarm\nThreshold: EstimatedCharges > $5"] C -->|"State: OK"| D["No Action"] C -->|"State: ALARM"| E["SNS Topic\narn:aws:sns:us-east-1:ACCOUNT:billing-alert"] E -->|"Publish"| F["Email Subscriber\n(Confirmed Endpoint)"] end F --> G["Developer Inbox\nAlert: Charges Exceeded $5"] style AWS_Global fill:#f0f4ff,stroke:#4a6fa5 style us_east_1 fill:#fff4e6,stroke:#e07b00 style C fill:#ffe0e0,stroke:#cc0000 style E fill:#e0f7e9,stroke:#2e7d32

Step-by-Step Implementation (CLI First)

Phase 1: Enable Billing Alerts (One-Time Account Setup)

Billing metrics are disabled by default. This must be enabled from the root account or an account with billing console access.

aws ce put-anomaly-monitor --anomaly-monitor ... # Not needed here
# Enable via Billing Preferences (console-only action, or use the following):
aws account put-alternate-contact ... # N/A

# The correct approach: enable via Billing Console preferences
# OR use this CLI workaround to verify it's already enabled:
aws cloudwatch list-metrics \
  --namespace AWS/Billing \
  --region us-east-1

Note: Enabling billing alerts requires navigating to Billing Console → Billing Preferences → Receive Billing Alerts. This specific toggle has no public CLI equivalent — it is a one-time manual step. Once enabled, the EstimatedCharges metric becomes available in CloudWatch.

Phase 2: Create the SNS Topic and Email Subscription

# Step 1: Create the SNS topic (must be in us-east-1)
aws sns create-topic \
  --name billing-alert \
  --region us-east-1

# Output: { "TopicArn": "arn:aws:sns:us-east-1:123456789012:billing-alert" }

# Step 2: Subscribe your email to the topic
aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:123456789012:billing-alert \
  --protocol email \
  --notification-endpoint your-email@example.com \
  --region us-east-1

After running the subscribe command, AWS sends a confirmation email. You must click the confirmation link before the alarm can deliver notifications. Until confirmed, the subscription status is PendingConfirmation.

Phase 3: Create the CloudWatch Billing Alarm

aws cloudwatch put-metric-alarm \
  --alarm-name "FreeTierBillingAlert" \
  --alarm-description "Alert when estimated charges exceed $5" \
  --metric-name EstimatedCharges \
  --namespace AWS/Billing \
  --statistic Maximum \
  --period 86400 \
  --evaluation-periods 1 \
  --threshold 5 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --dimensions Name=Currency,Value=USD \
  --alarm-actions arn:aws:sns:us-east-1:123456789012:billing-alert \
  --treat-missing-data notBreaching \
  --region us-east-1

Parameter Breakdown

ParameterValueReason
--statisticMaximumBilling metrics use Maximum, not Average — reflects the highest charge in the period
--period8640086400 seconds = 24 hours; billing data updates every ~6 hours but daily granularity is sufficient
--treat-missing-datanotBreachingPrevents false alarms when no billing data has been published yet (new accounts)
--dimensionsCurrency=USDRequired dimension to scope the metric to total USD charges

Phase 4: Verify Alarm State

aws cloudwatch describe-alarms \
  --alarm-names "FreeTierBillingAlert" \
  --region us-east-1 \
  --query 'MetricAlarms[0].{State:StateValue,Threshold:Threshold,SNS:AlarmActions}'

Expected output after email confirmation and metric data arrival:

{
    "State": "OK",
    "Threshold": 5.0,
    "SNS": ["arn:aws:sns:us-east-1:123456789012:billing-alert"]
}

IAM: Minimum Required Permissions

Apply least privilege. The identity creating this alarm needs only the following actions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BillingAlarmSetup",
      "Effect": "Allow",
      "Action": [
        "cloudwatch:PutMetricAlarm",
        "cloudwatch:DescribeAlarms",
        "cloudwatch:ListMetrics",
        "sns:CreateTopic",
        "sns:Subscribe",
        "sns:GetTopicAttributes"
      ],
      "Resource": "*"
    }
  ]
}

Critical: Enabling billing alerts in the Billing Console requires the aws-portal:ModifyBilling permission (or root access). This is separate from CloudWatch/SNS permissions and is often overlooked.

Cost Impact of This Solution

  • CloudWatch Alarm: The first 10 alarms per month are free under the AWS Free Tier. This single alarm costs $0.10/month outside the free tier — negligible.
  • SNS Email Notifications: Email notifications via SNS are free (no charge for email protocol deliveries).
  • Net cost: Effectively $0 within free tier, $0.10/month beyond it. This alarm pays for itself by preventing a single accidental charge.

Common Pitfalls

  • Wrong region: Running put-metric-alarm in us-west-2 or any region other than us-east-1 will create the alarm but it will never trigger — EstimatedCharges data is only published to us-east-1.
  • Unconfirmed SNS subscription: The alarm will fire and publish to SNS, but no email is delivered until the subscription endpoint is confirmed.
  • Billing alerts not enabled: If the Billing Preferences toggle is off, list-metrics --namespace AWS/Billing returns empty, and the alarm stays in INSUFFICIENT_DATA indefinitely.
  • $5 is not a hard limit: This alarm is a notification, not a spending cap. AWS does not automatically stop services when the threshold is crossed.

Glossary

  • EstimatedCharges: A CloudWatch metric in the AWS/Billing namespace that reflects the estimated total USD cost accrued in the current billing period, updated approximately every 6 hours.
  • SNS Topic: A Simple Notification Service logical channel that receives published messages and fans them out to all confirmed subscribers (email, SMS, Lambda, SQS, etc.).
  • CloudWatch Alarm: A stateful monitor that evaluates a metric against a threshold over a defined period and transitions between OK, ALARM, and INSUFFICIENT_DATA states.
  • INSUFFICIENT_DATA State: The initial alarm state when CloudWatch has not yet received enough metric data points to evaluate the threshold condition.
  • Least Privilege: An IAM security principle where an identity is granted only the minimum permissions required to perform its specific task — nothing more.

Comments

Popular posts from this blog

EC2 No Internet Access in Custom VPC: Attaching an Internet Gateway and Fixing Route Tables

Lambda Infinite Loop with S3: How to Break the Recursive Trigger Cycle

IAM User vs. IAM Role: Why Your EC2 Instance Should Never Use a User