Quick take — Provision AWS Budgets with Terraform and hashicorp/aws ~> 5.0: cost/usage budgets, multi-level percentage and forecasted thresholds, SNS plus email notifications, and cost filters — a reusable module. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "budget" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-budget?ref=v1.0.0"
name = "..." # Unique budget name shown in the Billing console (1–100 …
limit_amount = "..." # Budget limit as a string (API requirement), e.g. `"2000…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Budgets lets you set a planned spend or usage limit for an account (or a slice of it, scoped by tag, service, linked account, or other dimension) and fire notifications when actual or forecasted cost crosses a threshold. The underlying API is deceptively fiddly: the aws_budgets_budget resource mixes a limit_amount/limit_unit pair, an optional cost_types block of ~13 boolean toggles, a cost_filter block whose valid keys depend on the budget type, and a repeatable notification block where every entry needs its own comparison_operator, threshold, threshold_type, and a list of subscribers. Hand-writing that for every account drifts quickly — one team alerts at 80%, another forgets threshold_type = "PERCENTAGE" and silently configures an absolute-dollar trigger.
This module wraps aws_budgets_budget so a budget becomes a few well-named variables: a name, a monthly limit, a list of alert thresholds, and where to send the alerts. It encodes the production defaults you almost always want — a budget that tracks net unblended cost, a forecasted alert so you hear about overruns before the bill lands, and SNS fan-out so notifications reach Slack/PagerDuty rather than dying in an inbox. Cost filters are passed through as a clean map so you can scope a budget to a single tag or service without learning the API’s filter grammar.
When to use it
- You run more than one AWS account (Organizations, Control Tower, landing zones) and want an identical budget + alerting baseline stamped into every account from a pipeline.
- You need forecasted alerts, not just actual-spend alerts, so finance hears about a projected overrun mid-month while there is still time to act.
- You want budget notifications to land in SNS (then Slack, PagerDuty, or a Lambda auto-remediation) rather than relying on raw budget emails.
- You tag spend by
CostCenter,Project, orEnvironmentand want a per-tag or per-service budget that scopes cleanly via a cost filter. - You want budgets defined as reviewable code with sane, validated defaults instead of click-ops in the Billing console that nobody can audit.
Reach for plain aws_budgets_budget directly only for a genuine one-off. For anything repeated across accounts, environments, or cost centers, a module pays for itself on the second use.
Module structure
terraform-module-aws-budget/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Each threshold in var.notifications expands into one `notification` block.
# Subscribers are the union of the per-notification emails and a shared SNS topic.
notifications = [
for n in var.notifications : {
comparison_operator = n.comparison_operator
threshold = n.threshold
threshold_type = n.threshold_type
notification_type = n.notification_type
emails = n.emails
}
]
}
resource "aws_budgets_budget" "this" {
name = var.name
budget_type = var.budget_type
limit_amount = var.limit_amount
limit_unit = var.limit_unit
time_unit = var.time_unit
# Optional fixed window; omit both for a recurring (e.g. monthly) budget.
time_period_start = var.time_period_start
time_period_end = var.time_period_end
# cost_types only applies to COST budgets; AWS ignores it otherwise.
dynamic "cost_types" {
for_each = var.budget_type == "COST" ? [var.cost_types] : []
content {
include_credit = cost_types.value.include_credit
include_discount = cost_types.value.include_discount
include_other_subscription = cost_types.value.include_other_subscription
include_recurring = cost_types.value.include_recurring
include_refund = cost_types.value.include_refund
include_subscription = cost_types.value.include_subscription
include_support = cost_types.value.include_support
include_tax = cost_types.value.include_tax
include_upfront = cost_types.value.include_upfront
use_amortized = cost_types.value.use_amortized
use_blended = cost_types.value.use_blended
}
}
# Scope the budget: one cost_filter block per map entry (e.g. Service, TagKeyValue).
dynamic "cost_filter" {
for_each = var.cost_filters
content {
name = cost_filter.key
values = cost_filter.value
}
}
# One notification block per configured threshold.
dynamic "notification" {
for_each = { for idx, n in local.notifications : idx => n }
content {
comparison_operator = notification.value.comparison_operator
threshold = notification.value.threshold
threshold_type = notification.value.threshold_type
notification_type = notification.value.notification_type
subscriber_email_addresses = notification.value.emails
subscriber_sns_topic_arns = var.sns_topic_arn == null ? [] : [var.sns_topic_arn]
}
}
}
variables.tf
variable "name" {
description = "Unique name of the budget (shown in the Billing console). Keep it stable; renaming forces replacement."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 100
error_message = "name must be between 1 and 100 characters."
}
}
variable "budget_type" {
description = "What the budget tracks. One of COST, USAGE, RI_UTILIZATION, RI_COVERAGE, SAVINGS_PLANS_UTILIZATION, SAVINGS_PLANS_COVERAGE."
type = string
default = "COST"
validation {
condition = contains([
"COST", "USAGE", "RI_UTILIZATION", "RI_COVERAGE",
"SAVINGS_PLANS_UTILIZATION", "SAVINGS_PLANS_COVERAGE",
], var.budget_type)
error_message = "budget_type must be a valid AWS Budgets type."
}
}
variable "limit_amount" {
description = "Budget limit as a string (the AWS API expects a string), e.g. \"2000\" for $2000/month."
type = string
validation {
condition = can(tonumber(var.limit_amount)) && tonumber(var.limit_amount) > 0
error_message = "limit_amount must be a positive number expressed as a string."
}
}
variable "limit_unit" {
description = "Unit for the limit. \"USD\" for COST budgets; the relevant usage unit (e.g. GB, Hours) for USAGE budgets."
type = string
default = "USD"
}
variable "time_unit" {
description = "Length of the budget period: MONTHLY, QUARTERLY, ANNUALLY, or DAILY."
type = string
default = "MONTHLY"
validation {
condition = contains(["MONTHLY", "QUARTERLY", "ANNUALLY", "DAILY"], var.time_unit)
error_message = "time_unit must be MONTHLY, QUARTERLY, ANNUALLY, or DAILY."
}
}
variable "time_period_start" {
description = "Optional start of a fixed budget window (YYYY-MM-DD_HH:MM). Omit for a recurring budget."
type = string
default = null
}
variable "time_period_end" {
description = "Optional end of a fixed budget window (YYYY-MM-DD_HH:MM). Omit to run indefinitely (AWS defaults to 2087-06-15)."
type = string
default = null
}
variable "cost_types" {
description = "Which charge categories count toward a COST budget. Defaults track net unblended cost (no credits/refunds, taxes/support included)."
type = object({
include_credit = optional(bool, false)
include_discount = optional(bool, true)
include_other_subscription = optional(bool, true)
include_recurring = optional(bool, true)
include_refund = optional(bool, false)
include_subscription = optional(bool, true)
include_support = optional(bool, true)
include_tax = optional(bool, true)
include_upfront = optional(bool, true)
use_amortized = optional(bool, false)
use_blended = optional(bool, false)
})
default = {}
}
variable "cost_filters" {
description = "Map of cost filters to scope the budget. Key = filter name (e.g. Service, TagKeyValue, LinkedAccount), value = list of values. Empty = whole account."
type = map(list(string))
default = {}
}
variable "notifications" {
description = "Alert thresholds. Each entry creates one notification (actual or forecasted) at a percentage or absolute amount."
type = list(object({
comparison_operator = optional(string, "GREATER_THAN")
threshold = number
threshold_type = optional(string, "PERCENTAGE")
notification_type = optional(string, "ACTUAL")
emails = optional(list(string), [])
}))
default = [
{ threshold = 80, notification_type = "ACTUAL" },
{ threshold = 100, notification_type = "ACTUAL" },
{ threshold = 100, notification_type = "FORECASTED" },
]
validation {
condition = alltrue([
for n in var.notifications :
contains(["ACTUAL", "FORECASTED"], n.notification_type)
])
error_message = "Each notification_type must be ACTUAL or FORECASTED."
}
validation {
condition = alltrue([
for n in var.notifications :
contains(["GREATER_THAN", "LESS_THAN", "EQUAL_TO"], n.comparison_operator)
])
error_message = "comparison_operator must be GREATER_THAN, LESS_THAN, or EQUAL_TO."
}
validation {
condition = alltrue([
for n in var.notifications :
n.threshold_type != "PERCENTAGE" || (n.threshold >= 0 && n.threshold <= 1000000)
])
error_message = "A PERCENTAGE threshold must be between 0 and 1000000 (AWS allows >100% for forecasted alerts)."
}
}
variable "sns_topic_arn" {
description = "Optional SNS topic ARN added as a subscriber to every notification (for Slack/PagerDuty fan-out). The topic policy must allow budgets.amazonaws.com to publish."
type = string
default = null
}
outputs.tf
output "id" {
description = "Internal Terraform resource ID of the budget (AccountID:BudgetName)."
value = aws_budgets_budget.this.id
}
output "name" {
description = "Name of the budget."
value = aws_budgets_budget.this.name
}
output "arn" {
description = "ARN of the budget."
value = aws_budgets_budget.this.arn
}
output "budget_type" {
description = "The type of budget that was created (COST, USAGE, etc.)."
value = aws_budgets_budget.this.budget_type
}
output "limit_amount" {
description = "The configured limit amount."
value = aws_budgets_budget.this.limit_amount
}
How to use it
# An SNS topic that fans budget alerts out to Slack/PagerDuty.
resource "aws_sns_topic" "billing_alerts" {
name = "billing-budget-alerts"
}
# Budgets must be allowed to publish to the topic.
resource "aws_sns_topic_policy" "billing_alerts" {
arn = aws_sns_topic.billing_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowBudgetsPublish"
Effect = "Allow"
Principal = { Service = "budgets.amazonaws.com" }
Action = "SNS:Publish"
Resource = aws_sns_topic.billing_alerts.arn
}]
})
}
module "budgets" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-budget?ref=v1.0.0"
name = "prod-monthly-cost"
budget_type = "COST"
limit_amount = "2000"
limit_unit = "USD"
time_unit = "MONTHLY"
# Scope to a single cost center via tag, plus exclude refunds/credits (module defaults already do).
cost_filters = {
TagKeyValue = ["user:CostCenter$platform-eng"]
}
# Tiered alerting: warn at 80% actual, page at 100% actual, and at a 110% forecast.
notifications = [
{ threshold = 80, notification_type = "ACTUAL", emails = ["finops@kloudvin.com"] },
{ threshold = 100, notification_type = "ACTUAL", emails = ["finops@kloudvin.com"] },
{ threshold = 110, notification_type = "FORECASTED" },
]
sns_topic_arn = aws_sns_topic.billing_alerts.arn
}
# Downstream reference: surface the budget ARN in stack outputs / a cost dashboard.
output "prod_budget_arn" {
value = module.budgets.arn
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/budget/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-budget?ref=v1.0.0"
}
inputs = {
name = "..."
limit_amount = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/budget && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
n/a | Yes | Unique budget name shown in the Billing console (1–100 chars). Renaming forces replacement. |
budget_type |
string |
"COST" |
No | COST, USAGE, RI_UTILIZATION, RI_COVERAGE, SAVINGS_PLANS_UTILIZATION, or SAVINGS_PLANS_COVERAGE. |
limit_amount |
string |
n/a | Yes | Budget limit as a string (API requirement), e.g. "2000". |
limit_unit |
string |
"USD" |
No | USD for cost budgets; the usage unit (GB, Hours, …) for usage budgets. |
time_unit |
string |
"MONTHLY" |
No | Budget period: MONTHLY, QUARTERLY, ANNUALLY, or DAILY. |
time_period_start |
string |
null |
No | Optional fixed-window start (YYYY-MM-DD_HH:MM). Omit for recurring. |
time_period_end |
string |
null |
No | Optional fixed-window end (YYYY-MM-DD_HH:MM). Omit to run indefinitely. |
cost_types |
object(...) |
{} (net unblended cost) |
No | Toggles for which charge categories count toward a COST budget. |
cost_filters |
map(list(string)) |
{} |
No | Filters scoping the budget (e.g. Service, TagKeyValue, LinkedAccount). Empty = whole account. |
notifications |
list(object(...)) |
80% + 100% actual, 100% forecasted | No | Alert thresholds; each entry becomes one actual/forecasted notification. |
sns_topic_arn |
string |
null |
No | SNS topic added as a subscriber to every notification. Topic policy must allow budgets.amazonaws.com. |
Outputs
| Name | Description |
|---|---|
id |
Internal Terraform resource ID (AccountID:BudgetName). |
name |
Name of the budget. |
arn |
ARN of the budget. |
budget_type |
The type of budget created (COST, USAGE, etc.). |
limit_amount |
The configured limit amount. |
Enterprise scenario
A fintech with 60 AWS accounts under Control Tower stamps this module into every account from its account-baseline pipeline: each account gets a MONTHLY COST budget sized from the team’s approved annual plan, with 80% actual, 100% actual, and 110% forecasted alerts wired to a central SNS topic. That topic triggers a Lambda that posts to the owning team’s Slack channel and opens a low-priority Jira ticket on the forecasted breach. Because the forecasted alert fires mid-month, FinOps catches a runaway Glue job or an unexpected NAT Gateway spend while there is still budget runway to react, rather than discovering it on the invoice.
Best practices
- Always include a FORECASTED notification, not just ACTUAL. Actual-spend alerts at 100% tell you the money is already gone; a forecasted alert (often set just above 100%, e.g. 110%) warns you mid-cycle while you can still throttle or shut down the offending workload.
- Send to SNS, not just inbox emails — and lock down the topic. Wire
sns_topic_arnso alerts fan out to Slack/PagerDuty/Lambda, and scope the topic policy toPrincipal = { Service = "budgets.amazonaws.com" }withSNS:Publishonly, so nothing else can spoof budget alerts. - Set budgets in the management account but scope with
cost_filters. Budgets are billing-global; use aLinkedAccountorTagKeyValuefilter to carve per-account or per-cost-center views instead of one blunt org-wide number that hides where the spend actually is. - Be deliberate about
cost_types. Decide up front whether the budget tracks blended vs. unblended and amortized vs. unamortized cost, and whether credits/refunds count — otherwise a credit can mask a real overrun. The module defaults to net unblended cost (credits and refunds excluded), which matches how most teams reason about run-rate. - Keep
namestable and descriptive. Renaming forces replacement (and a brief gap in alerting), so adopt a convention like<account>-<period>-<type>(e.g.prod-monthly-cost) from day one and never churn it casually. - Treat budgets as a guardrail, not enforcement. Budgets alert; they do not stop spend. Pair them with Service Control Policies, AWS Budgets Actions, or automated remediation if you need a hard cap rather than a heads-up.