IaC AWS

Terraform Module: AWS Budgets — guardrail spend limits with multi-threshold alerts as code

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

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 configlive/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 configlive/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

TerraformAWSBudgetsModuleIaC
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

Keep Reading