Add an AWS Budget Alert for Monthly Cloud Spend
Provision a monthly AWS Cost Budget with actual and forecasted thresholds that publish to an SNS topic, so spend anomalies reach the team before the invoice.
Task
Create an AWS monthly cost budget with actual and forecasted alerts delivered to a confirmed SNS email subscription.
When to use this
A staging cluster or a runaway data transfer can double the monthly bill, but nobody notices until the invoice is issued at month end.
Prerequisites
- AWS CLI v2 authenticated with permissions for budgets, SNS, and IAM policy reads
- Terraform 1.6 or later with the AWS provider 5.x
- A monitored email address that can confirm an SNS subscription
Steps
1. Create the Terraform module with a pinned AWS provider and input variables for the alert email and monthly limit, then initialize it.
terraform -chdir=infra/budget initterraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
variable "alert_email" {
type = string
}
variable "monthly_limit_usd" {
type = number
default = 500
}
Expected output: Terraform has been successfully initialized and the hashicorp/aws provider is installed.
2. Create the SNS topic, email subscription, and a topic policy that allows only the Budgets service in this account to publish.
data "aws_caller_identity" "current" {}
resource "aws_sns_topic" "budget_alerts" {
name = "monthly-cost-budget-alerts"
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.budget_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
data "aws_iam_policy_document" "budget_publish" {
statement {
sid = "AllowOwnerAdmin"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
}
actions = ["SNS:*"]
resources = [aws_sns_topic.budget_alerts.arn]
}
statement {
sid = "AllowBudgetsPublish"
effect = "Allow"
principals {
type = "Service"
identifiers = ["budgets.amazonaws.com"]
}
actions = ["SNS:Publish"]
resources = [aws_sns_topic.budget_alerts.arn]
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}
}
}
resource "aws_sns_topic_policy" "budget_alerts" {
arn = aws_sns_topic.budget_alerts.arn
policy = data.aws_iam_policy_document.budget_publish.json
}
Expected output: The module defines one SNS topic, one email subscription, and a publish policy scoped to the current account.
3. Create an account-wide monthly cost budget with an 80 percent actual alert and a 100 percent forecasted alert, and expose the topic ARN as an output.
resource "aws_budgets_budget" "monthly" {
name = "monthly-account-budget"
budget_type = "COST"
limit_amount = tostring(var.monthly_limit_usd)
limit_unit = "USD"
time_unit = "MONTHLY"
depends_on = [aws_sns_topic_policy.budget_alerts]
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.budget_alerts.arn]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_sns_topic_arns = [aws_sns_topic.budget_alerts.arn]
}
}
output "budget_topic_arn" {
value = aws_sns_topic.budget_alerts.arn
}
Expected output: The budget is account-wide, uses USD, and has two notification blocks attached to the SNS topic.
4. Apply the module with a real alert address and a 500 USD monthly limit, then confirm the SNS subscription from the email inbox.
terraform -chdir=infra/budget apply -var="alert_email=$ALERT_EMAIL" -var="monthly_limit_usd=500"Expected output: Apply complete with 4 resources added; AWS sends a subscription confirmation email, and clicking the link changes the subscription to confirmed.
5. Verify the budget and subscription with the AWS CLI, then publish a test message through the same topic.
aws budgets describe-budget \
--account-id "$(aws sts get-caller-identity --query Account --output text)" \
--budget-name monthly-account-budget \
--query 'Budget.{Name:BudgetName,Limit:BudgetLimit.Amount,Unit:BudgetLimit.Unit}' \
--output table
aws sns list-subscriptions-by-topic \
--topic-arn "$(terraform -chdir=infra/budget output -raw budget_topic_arn)" \
--query 'Subscriptions[].[Protocol,Endpoint,SubscriptionArn]' \
--output table
aws sns publish \
--topic-arn "$(terraform -chdir=infra/budget output -raw budget_topic_arn)" \
--subject 'Budget alert test' \
--message 'This is a test of the monthly budget alert path.'
Expected output: The budget table shows 500 USD monthly; the subscription table shows the confirmed email; sns publish returns a MessageId and the test email arrives.
Troubleshooting
Problem
Terraform fails with Duplicate budget name
Solution
A budget with that name already exists in the account. Import it with terraform import aws_budgets_budget.monthly "$(aws sts get-caller-identity --query Account --output text):monthly-account-budget" or choose a new name.
Problem
The budget exists but no email arrives
Solution
Confirm the SNS subscription, verify the topic policy allows budgets.amazonaws.com, and check that the budget notifications reference the topic ARN.
Problem
The forecasted alert fires repeatedly or too early
Solution
Forecasts are recalculated continuously and can cross a threshold more than once. Raise the forecast threshold or notify only on ACTUAL spend.
Limitations
- AWS Budgets refreshes cost data a few times per day, so alerts are not real-time.
- An account-wide budget does not identify which team or service caused the spend; add cost filters or Cost Anomaly Detection.
- SNS email is not a paging channel; subscribe an HTTPS endpoint to PagerDuty or Slack for on-call delivery.
Budgets are guardrails, not invoices
A monthly cost budget compares actual or forecasted spend with a fixed amount and publishes a notification when a threshold is crossed. It does not stop resources. That distinction matters: the budget gives the team time to find a runaway workload, while an SCP or service quota is what actually caps spend. Use both when the account supports it, but start with a budget because it is cheap, account-wide, and independent of application code.
Use two notifications. The actual 80 percent alert is the early warning that this month is on track to exceed the plan. The forecasted 100 percent alert fires when the projected month-end total crosses the limit, which can happen early in the billing period. Forecast alerts are intentionally noisy near the threshold; if the team ignores them, raise the threshold or route only actual alerts to the on-call channel and send forecasts to a cost-review channel.
The SNS path is part of the configuration
Budget notifications are published to an SNS topic, and SNS email subscriptions require confirmation. The topic policy must allow the Budgets service principal to publish, scoped to the account with aws:SourceAccount, otherwise the budget is created but notifications fail silently. The depends_on on the topic policy prevents a race where Budgets validates publish permission before the policy is attached.
Terraform tracks the subscription as created even while it is pending confirmation. That is why the apply can succeed while the first email has not arrived. Treat confirmation as a human step in the runbook, and verify it with list-subscriptions-by-topic instead of assuming the alert path works.
Verify the whole path
describe-budget proves the limit, unit, and name. The subscription table proves the endpoint is confirmed. Publishing a test message proves the topic can deliver to the inbox, but it does not prove the Budgets service can publish; the topic policy and a real threshold crossing cover that part. For a stronger test, set the limit temporarily below current spend in a sandbox account, wait for the next budget evaluation, and confirm the alert body names the budget and threshold.
Pitfalls
Do not scope the budget to a single service unless you also have budgets for the account total; a runaway NAT gateway or data transfer charge will not appear in an EC2-only budget. Do not use the budget as the only cost control for a sandbox account with payment credentials. And keep the alert email distribution list current, because a budget that pages a departed employee is effectively no budget at all.
Example
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
variable "alert_email" {
type = string
}
variable "monthly_limit_usd" {
type = number
default = 500
}