Secure-by-default cost/usage guardrail — a cost, usage, Savings Plans, or Reserved Instance budget with SNS-first alerting and manual-approval-by-default remediation actions (IAM policy attach, SCP attach, or SSM stop-instance), from a single composite call. Built for the AWS provider v6.x.
- 💰 Provisions an
aws_budgets_budgetkeystone — cost, usage, Savings Plans utilization/coverage, or RI utilization/coverage — supporting both the legacycost_filter/cost_typesfamily and the modernfilter_expression/metricsfamily (mutually exclusive; pick one). - 🔔 SNS-first notifications. Every
notificationsentry defaults tovar.sns_topic_arnso budget alerts land in existing alerting/paging pipelines; email is an additive opt-in, never the sole channel by default. - 🛑 Manual-approval-by-default remediation. The optional
aws_budgets_budget_actionchild collection (for_eachovermap(object(...))) applies an IAM policy, attaches an SCP, or stops EC2/RDS instances when a threshold fires — every action defaultsapproval_model = "MANUAL"so a human reviews before anything is attached or stopped. - 🧮 Ramp and adjust. Optional
planned_limitmap for a phased/seasonal spend schedule, andauto_adjust_datafor aFORECAST- orHISTORICAL-driven moving budget. - 🔗 Deliberately narrow blast radius. The SNS topic, IAM execution role, IAM policy, and SCP are all consumed by reference (ARN/id) only — this module never creates them, keeping the Budgets API surface isolated from identity and alerting, which are governed by their own sibling modules.
- 🏷️ Universal tagging on both resources —
var.tagsflows to the budget and merges per-action (merge(var.tags, each.value.tags)) on every budget action;tags_allsurfaced as an output on the budget. - 🌎 No
regionvariable, no us-east-1 alias. Budgets is a billing-plane API auto-routed tous-east-1regardless of the caller's configured Region — different from CloudFront/ACM/WAFv2, which need an explicitproviders = { aws = aws.us_east_1 }alias.
💡 Why it matters: A regulated FI's biggest cost-control risk isn't the alert — it's an unattended remediation that mis-fires (a transient spike auto-attaching a
Denypolicy, or an SCP boundary flipping across the account structure). This module makes the guardrail loud (SNS) and the trigger finger deliberately slow (manual approval) unless a caller explicitly opts out per action.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
terraform-aws-budgets is a cost-management leaf — it consumes references from identity, alerting, and (for SCP actions only) organization-governance modules, but nothing consumes it as a foundation the way compute/networking modules consume terraform-aws-vpc. Its sibling terraform-aws-cost-management owns cost allocation tags, Cost Anomaly Detection, and CUR — this module owns the budget guardrail and its remediation workflow only.
flowchart LR
budgets["terraform-aws-budgets"]
sns["terraform-aws-sns"]
role["terraform-aws-iam-role"]
policy["terraform-aws-iam-policy"]
org["terraform-aws-organizations<br/>(Phase 7, mgmt-account only)"]
costmgmt["terraform-aws-cost-management<br/>(sibling — CUR / anomaly detection)"]
cwlg["CloudWatch / paging pipeline"]
sns -. "sns_topic_arn (notifications + action subscribers)".-> budgets
role -. "execution_role_arn (budget_actions[*])".-> budgets
policy -. "iam_policy_arn (APPLY_IAM_POLICY actions)".-> budgets
org -. "scp_policy_id (APPLY_SCP_POLICY actions, mgmt-account only)".-> budgets
budgets -- "budget/action ARNs (IAM policy conditions)" --> policy
sns -- "alert delivery" --> cwlg
costmgmt -. "shares cost-allocation tags / CUR context".-> budgets
style budgets fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
flowchart TB
subgraph BUDGETMOD["terraform-aws-budgets"]
budget["aws_budgets_budget.this<br/>(keystone)"]
subgraph LEGACY["Legacy cost-filter family"]
costfilter["cost_filter[]"]
costtypes["cost_types"]
end
subgraph MODERN["Modern filter-expression family"]
filterexpr["filter_expression<br/>(and/or/not, depth 2)"]
metrics["metrics[]"]
end
planned["planned_limit[]<br/>(ramp schedule)"]
autoadj["auto_adjust_data<br/>(FORECAST/HISTORICAL)"]
notif["notification[]<br/>(SNS-first, email additive)"]
action["aws_budgets_budget_action.this<br/>(for_each, tied via budget_name)"]
subgraph DEFN["definition (exactly one)"]
iamdef["iam_action_definition"]
scpdef["scp_action_definition<br/>(mgmt-account only)"]
ssmdef["ssm_action_definition"]
end
sub["subscriber[]<br/>(SNS-first, email additive)"]
end
budget --> LEGACY & MODERN & planned & autoadj & notif
budget -- "budget_name" --> action
action --> DEFN
action --> sub
style budget fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
| Resource | Role | Cardinality |
|---|---|---|
aws_budgets_budget.this |
Keystone budget — cost/usage/RI/Savings-Plans tracking, filters, notifications, planned limits | 1 |
aws_budgets_budget_action.this |
Automated/manual-approval remediation tied to the keystone via budget_name |
per budget_actions entry (0-n) |
ℹ️ Both resources are rendered by
dynamicblocks over caller-supplied variables (cost_filter,cost_types,filter_expression,planned_limit,auto_adjust_data,notificationon the budget;iam_action_definition/scp_action_definition/ssm_action_definition/subscriber/timeoutson each action) — none of these are separate Terraform resources, so the resource count stays at 2 regardless of how many filters, notifications, or actions a caller configures.
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 |
No provider {} block is declared inside the module — the caller's configured provider is inherited. Budgets API calls are routed to us-east-1 by AWS regardless of the provider's configured Region (see AWS Prerequisites).
Least-privilege actions the Terraform identity needs to create, read, update, and delete everything this module manages:
| Action | Required for | Notes |
|---|---|---|
budgets:CreateBudget, budgets:DescribeBudget, budgets:DescribeBudgets, budgets:ModifyBudget, budgets:DeleteBudget |
aws_budgets_budget lifecycle |
— |
budgets:DescribeBudgetPerformanceHistory |
Read-back during plan/refresh |
Relevant to auto_adjust_data / planned_limit budgets |
budgets:CreateBudgetAction, budgets:DescribeBudgetAction, budgets:DescribeBudgetActionsForBudget, budgets:UpdateBudgetAction, budgets:DeleteBudgetAction |
aws_budgets_budget_action lifecycle |
— |
budgets:ExecuteBudgetAction |
Only if the Terraform identity itself must trigger a run | Normally left to the console/API approval workflow — see AWS Prerequisites |
iam:PassRole (scoped to the specific execution_role_arn) |
Passing the execution role to Budgets when creating/updating a budget action | Scope the resource to the exact role ARN, never * |
iam:CreateServiceLinkedRole (service budgets.amazonaws.com) |
Only if billing_view_arn references a cross-account billing view |
AWS auto-creates AWSServiceRoleForBudgets on the first CreateBudget/UpdateBudget call that uses it — not needed for same-account budgets (this module's default) |
organizations:DescribeOrganization |
Read-only check some Budgets Actions tooling performs for APPLY_SCP_POLICY |
Actually attaching the SCP requires management-account credentials — see below |
ℹ️ No general service-linked-role auto-creation. Unlike GuardDuty/Security Hub, Budgets does not create
AWSServiceRoleForBudgetson ordinary same-account cost/usage budget creation — only when a shared cross-account billing view is referenced. The Terraform identity does not neediam:CreateServiceLinkedRolefor the module's default posture.
- Account scoping is broader than the "management account only" assumption. Both
aws_budgets_budgetandaws_budgets_budget_actionexpose an optionalaccount_idthat defaults to the caller's own account. A linked/member account can create and manage its own budgets using its own credentials — Budgets is not restricted to the payer/management account for standard cost/usage budgets, member-account SCP attachment is the exception (next bullet). - The management-account restriction applies specifically to
APPLY_SCP_POLICYactions. AWS's own budget-action configuration guidance states plainly: "Only a management account can apply SCPs."APPLY_IAM_POLICYandRUN_SSM_DOCUMENTSactions run in any account, member or management — do not generalize the SCP restriction to budgets or to other action types. - Service-linked role
AWSServiceRoleForBudgetsis created automatically the first timeCreateBudget/UpdateBudgetreferences abilling_view_arnshared from another account (it grants Budgetsbilling:GetBillingViewDataon billing views the caller has access to). It is not required for ordinary same-account cost/usage budgets — the module's default posture (billing_view_arn = null). - Budget action execution role.
execution_role_arnmust be an IAM role in the same account as the action, trusted bybudgets.amazonaws.com, with permissions to perform the target action. AWS publishes managed policies for this (e.g.AWSBudgetsActionsWithAWSResourceControlAccess) — this module never creates the role; wire it fromterraform-aws-iam-role. - Approval workflow. A budget action with
approval_model = "MANUAL"shows as Requires approval on the Budget details page; a human reviews and chooses Run action. The action-history table tracks Standby → Requires approval → Completed, with a Reversed state to undo a completed action (and a Reset to re-evaluate it in the same period). AWS retains 60 days of action history in the console; longer-lived audit trails come from CloudTrail orDescribeBudgetActionHistories. - SNS delivery confirmation. The first notification to a brand-new
subscriber_sns_topic_arnsvalue triggers SNS's standard subscription-confirmation handshake; no action needed if the topic already has a confirmed subscriber. - No Region constraint / not a global resource in the CloudFront sense.
aws_budgets_budgetandaws_budgets_budget_actionare billing-plane resources — AWS routes Budgets API calls tous-east-1automatically regardless of the caller's configured Region. Noproviders = { aws = aws.us_east_1 }pattern is needed or supported in this module (unliketerraform-aws-cloudfront/terraform-aws-acm/terraform-aws-wafv2). - Quotas. Budgets and budget actions are subject to a soft per-account quota (adjustable via Service Quotas / a support request) — confirm current values before provisioning at scale (e.g. one budget per cost center).
terraform-aws-budgets/
├── providers.tf # terraform{} + required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name, budget_type/time_unit, filters (legacy+modern), notifications, budget_actions, tags
├── main.tf # locals (subscriber defaulting) + check block + aws_budgets_budget.this + aws_budgets_budget_action.this
├── outputs.tf # id + arn, name, tags_all, budget_action_ids/arns/status
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM, prerequisites, emits, gotchas
module "sns_alerts" {
source = "git::https://github.com/microsoftexpert/terraform-aws-sns?ref=v1.0.0"
name = "finops-budget-alerts"
}
module "monthly_cost_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "app-monthly-cost-guardrail"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "10000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn # secure-by-default SNS channel
notifications = {
actual-80pct = { threshold = 80 }
}
tags = {
Environment = "prod"
CostCenter = "app-eng"
}
}
⚠️ Pin the source with?ref=v1.0.0— never a branch. Wiresns_topic_arnfromterraform-aws-snsto activate the secure-by-default alerting channel; without it everynotifications/budget_actionsentry must supply its own subscribers or the plan fails validation.
| Input | Type | Source module |
|---|---|---|
sns_topic_arn |
string (ARN) |
terraform-aws-sns — module-wide default subscriber for notifications and action subscribers |
budget_actions[*].execution_role_arn |
string (ARN, required per action) |
terraform-aws-iam-role — role trusted by budgets.amazonaws.com |
budget_actions[*].iam_action_definition.policy_arn |
string (ARN, per APPLY_IAM_POLICY action) |
terraform-aws-iam-policy |
budget_actions[*].scp_action_definition.policy_id |
string (id, per APPLY_SCP_POLICY action) |
terraform-aws-organizations (Phase 7) — management account only |
billing_view_arn |
string (ARN, optional) |
A cross-account billing view, out of Phase 1/4 module scope |
| Output | Description | Consumed by |
|---|---|---|
id |
Budget id — composite AccountID:BudgetName (matches the import format) |
Reference / import tooling |
arn |
Budget ARN — arn:aws:budgets::<account-id>:budget/<budget-name> — cross-resource reference type |
IAM policy conditions scoping budgets:* actions to this budget |
name |
Budget name | Dashboards, alerting runbooks, cost-center tagging |
tags_all |
Computed merge of var.tags over provider default_tags |
Governance/audit |
budget_action_ids |
Map (keyed by the caller's budget_actions key) of each action's action_id |
Action-history lookups, ExecuteBudgetAction calls |
budget_action_arns |
Map of each action's ARN | IAM policy conditions scoping approval/execution permissions per action |
budget_action_status |
Map of each action's current status (STANDBY / PENDING / EXECUTION_IN_PROGRESS / EXECUTION_SUCCESS / etc.) |
Read-only drift/audit visibility — a plan-time snapshot, not the live console state |
1 · Minimal — cost budget wired to an SNS topic
module "sns_alerts" {
source = "git::https://github.com/microsoftexpert/terraform-aws-sns?ref=v1.0.0"
name = "finops-budget-alerts"
}
module "cost_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "org-monthly-cost"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "5000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
notifications = {
actual-80pct = { threshold = 80 }
}
}2 · Usage budget — S3 storage in GB
module "s3_usage_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "s3-storage-usage"
budget_type = "USAGE"
time_unit = "MONTHLY"
limit_amount = "500"
limit_unit = "GB"
cost_filter = [
{ name = "Service", values = ["Amazon Simple Storage Service"] }
]
sns_topic_arn = module.sns_alerts.arn
notifications = {
actual-90pct = { threshold = 90 }
}
}3 · Savings Plans / RI utilization budget (required cost_types override)
# RI/Savings-Plans utilization budgets conflict with the AWS-default cost_types
# behavior -- cost_types must be set explicitly (enforced by a variable
# validation that fails at plan-time if omitted for these budget_type values).
module "ri_utilization_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "rds-ri-utilization"
budget_type = "RI_UTILIZATION"
time_unit = "MONTHLY"
limit_amount = "100.0" # RI utilization budgets must target 100
limit_unit = "PERCENTAGE"
# RI utilization plans also require a Service cost filter.
cost_filter = [
{ name = "Service", values = ["Amazon Relational Database Service"] }
]
cost_types = {
include_credit = false
include_discount = false
include_other_subscription = false
include_recurring = false
include_refund = false
include_subscription = true
include_support = false
include_tax = false
include_upfront = false
use_blended = false
}
sns_topic_arn = module.sns_alerts.arn
notifications = {
below-80pct = { threshold = 80, comparison_operator = "LESS_THAN" }
}
}4 · Modern filter_expression with AND/OR nesting
module "filtered_cost_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "ec2-prod-not-uswest2"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "3000"
limit_unit = "USD"
# Modern family: filter_expression + metrics travel together (enforced by a
# `check` block in main.tf -- see Architecture Notes on why this couldn't be
# a variable validation).
metrics = ["UnblendedCost"]
filter_expression = {
and = [
{ dimensions = { key = "SERVICE", values = ["Amazon Elastic Compute Cloud - Compute"] } },
{ not = { dimensions = { key = "REGION", values = ["us-west-2"] } } },
]
}
sns_topic_arn = module.sns_alerts.arn
notifications = {
forecast-100pct = { threshold = 100, notification_type = "FORECASTED" }
}
}5 · Auto-adjusting historical budget + planned-limit ramp schedule
module "seasonal_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "seasonal-campaign-spend"
budget_type = "COST"
time_unit = "MONTHLY"
# Either auto_adjust_data OR planned_limit drives the amount -- shown
# separately below; do not combine both on the same budget in practice.
auto_adjust_data = {
auto_adjust_type = "HISTORICAL"
historical_options = {
budget_adjustment_period = 3 # trailing 3-month moving average
}
}
sns_topic_arn = module.sns_alerts.arn
notifications = {
actual-100pct = { threshold = 100 }
}
}
module "ramped_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "phased-rollout-spend"
budget_type = "COST"
time_unit = "MONTHLY"
planned_limit = {
"2027-01-01_00:00" = { amount = "1000", unit = "USD" }
"2027-02-01_00:00" = { amount = "2500", unit = "USD" }
"2027-03-01_00:00" = { amount = "5000", unit = "USD" }
}
sns_topic_arn = module.sns_alerts.arn
}6 · Budget action — manual approval (secure default), execution role from terraform-aws-iam-role
module "budgets_execution_role" {
source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
name = "casey-budgets-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "budgets.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
managed_policy_arns = ["arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess"]
}
module "guarded_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "app-cost-guardrail"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "10000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
budget_actions = {
lock-ec2 = {
action_type = "APPLY_IAM_POLICY"
execution_role_arn = module.budgets_execution_role.arn
# approval_model left unset -- defaults to "MANUAL": a human reviews and
# runs the action from the Budget details "Requires approval" page.
action_threshold = {
action_threshold_type = "PERCENTAGE"
action_threshold_value = 100
}
iam_action_definition = {
policy_arn = module.deny_ec2_policy.arn # terraform-aws-iam-policy
roles = [module.app_workload_role.name]
}
}
}
}7 · Budget action — AUTOMATIC opt-out (unattended remediation — risk callout)
# ⚠️ RISK: approval_model = "AUTOMATIC" removes the human-review gate. AWS
# Budgets will attach this IAM policy the moment the threshold is exceeded --
# a transient cost spike or filter misconfiguration can lock out legitimate
# workloads with no human in the loop. Reserve this for well-tested,
# low-blast-radius actions (e.g. a narrowly scoped read-only policy) after
# change-management review, never as the default posture.
module "auto_remediated_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "sandbox-auto-lockdown"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "500"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
budget_actions = {
auto-deny-new-resources = {
action_type = "APPLY_IAM_POLICY"
approval_model = "AUTOMATIC" # opt-out -- documented exception
execution_role_arn = module.budgets_execution_role.arn
action_threshold = {
action_threshold_type = "ABSOLUTE_VALUE"
action_threshold_value = 500
}
iam_action_definition = {
policy_arn = module.deny_all_provisioning_policy.arn
roles = [module.sandbox_role.name]
}
}
}
}8 · Budget action — APPLY_SCP_POLICY (management-account-only)
# Only a management account can successfully execute an APPLY_SCP_POLICY
# action -- an AWS Organizations constraint enforced by AWS, not by this
# module. Author this from Terraform running with management-account
# credentials.
module "org_wide_cost_guardrail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "org-quarterly-cost"
budget_type = "COST"
time_unit = "QUARTERLY"
limit_amount = "250000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
budget_actions = {
scp-deny-new-spend = {
action_type = "APPLY_SCP_POLICY"
execution_role_arn = module.budgets_execution_role.arn
action_threshold = {
action_threshold_type = "PERCENTAGE"
action_threshold_value = 100
}
scp_action_definition = {
policy_id = module.deny_new_spend_scp.id # terraform-aws-organizations (Phase 7)
target_ids = ["ou-casey-workloads"]
}
# approval_model defaults to "MANUAL" -- unchanged even for SCP actions.
}
}
}9 · Tags — merge with provider default_tags
# Caller's provider block owns default_tags; resource tags win on key conflict.
provider "aws" {
default_tags {
tags = { Owner = "finops", ManagedBy = "terraform" }
}
}
module "tagged_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "tagged-cost-guardrail"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "8000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
tags = {
Environment = "prod" # resource tag -- wins over default_tags on key conflict
CostCenter = "app-eng"
}
budget_actions = {
lock-ec2 = {
action_type = "APPLY_IAM_POLICY"
execution_role_arn = module.budgets_execution_role.arn
action_threshold = { action_threshold_type = "PERCENTAGE", action_threshold_value = 100 }
iam_action_definition = {
policy_arn = module.deny_ec2_policy.arn
roles = [module.app_workload_role.name]
}
tags = { Purpose = "ec2-lockdown" } # merges over module tags for this action only
}
}
}
# module.tagged_budget.tags_all == { Owner, ManagedBy, Environment, CostCenter }
# aws_budgets_budget_action.this["lock-ec2"].tags == { Owner, ManagedBy, Environment, CostCenter, Purpose }10 · Secure-by-default opt-out — additive email channel (not a replacement)
# notification_email_addresses is ADDITIVE on top of SNS -- there is no
# variable that makes email the sole channel; var.sns_topic_arn (or a
# per-entry subscriber_sns_topic_arns) is still required to satisfy the
# "at least one subscriber channel" validation unless the entry supplies its
# own subscribers.
module "budget_with_email_cc" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "finance-cost-guardrail"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "6000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn # still the primary channel
notifications = {
actual-80pct = {
threshold = 80
notification_email_addresses = ["[email protected]"] # additive CC
}
}
}11 · for_each pattern — multiple departmental budgets from one caller-side map
locals {
department_budgets = {
finance = { limit = "5000" }
it = { limit = "8000" }
lending = { limit = "12000" }
}
}
module "dept_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
for_each = local.department_budgets
name = "dept-${each.key}-monthly-cost"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = each.value.limit
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
notifications = {
actual-80pct = { threshold = 80 }
}
tags = { CostCenter = each.key }
}12 · Member-account budget with an explicit account_id override
# Runs from a shared Terraform identity that has cross-account access; the
# budget itself is scoped to the member account via account_id, not the
# caller's own account (the default when account_id is left null).
module "member_account_budget" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "member-workload-cost"
account_id = "222233334444" # linked/member account -- not management-account-only
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "3000"
limit_unit = "USD"
sns_topic_arn = module.sns_alerts.arn
}13 · Import block — non-standard composite keys
# Budgets/Budget Actions use composite import keys, not a plain resource id.
import {
to = module.cost_budget.aws_budgets_budget.this
id = "123456789012:org-monthly-cost" # AccountID:BudgetName
}
import {
to = module.guarded_budget.aws_budgets_budget_action.this["lock-ec2"]
id = "123456789012:ab-abc123XYZ:app-cost-guardrail" # AccountID:ActionID:BudgetName
}14 · End-to-end composition (finale) — SNS + IAM role + IAM policy + this module
# 1. SNS topic FinOps alerts route through
module "finops_alerts" {
source = "git::https://github.com/microsoftexpert/terraform-aws-sns?ref=v1.0.0"
name = "finops-budget-alerts"
subscriptions = {
finops-team = { protocol = "email", endpoint = "[email protected]" }
}
}
# 2. Workload role the remediation action restricts
module "app_workload_role" {
source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
name = "casey-lending-portal-app"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# 3. Least-privilege policy the budget action attaches when triggered
module "deny_new_ec2_policy" {
source = "git::https://github.com/microsoftexpert/terraform-aws-iam-policy?ref=v1.0.0"
name = "casey-deny-new-ec2-provisioning"
policy = data.aws_iam_policy_document.deny_ec2.json
}
# 4. Execution role AWS Budgets assumes to run the action
module "budgets_execution_role" {
source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
name = "casey-budgets-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "budgets.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
managed_policy_arns = ["arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess"]
}
# 5. Budget + manual-approval remediation action, all wired together
module "app_cost_guardrail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-budgets?ref=v1.0.0"
name = "app-monthly-cost-guardrail"
budget_type = "COST"
time_unit = "MONTHLY"
limit_amount = "10000"
limit_unit = "USD"
sns_topic_arn = module.finops_alerts.arn
notifications = {
actual-80pct = { threshold = 80 }
forecast-100pct = { threshold = 100, notification_type = "FORECASTED" }
}
budget_actions = {
lock-ec2 = {
action_type = "APPLY_IAM_POLICY"
execution_role_arn = module.budgets_execution_role.arn
# approval_model left at the secure "MANUAL" default.
action_threshold = {
action_threshold_type = "PERCENTAGE"
action_threshold_value = 100
}
iam_action_definition = {
policy_arn = module.deny_new_ec2_policy.arn
roles = [module.app_workload_role.name]
}
}
}
tags = { Environment = "prod", App = "lending-portal", CostCenter = "app-eng" }
}ℹ️ High-level grouping:
- Identity:
name - Required:
budget_type(COST|USAGE|RI_UTILIZATION|RI_COVERAGE|SAVINGS_PLANS_UTILIZATION|SAVINGS_PLANS_COVERAGE),time_unit(MONTHLY|QUARTERLY|ANNUALLY|DAILY) - Optional budget config:
account_id,billing_view_arn,limit_amount,limit_unit,time_period_start,time_period_end,auto_adjust_data(object:auto_adjust_type,historical_options) - Cost filtering — legacy family:
cost_filter(list of{ name, values }),cost_types(object of 11include_*/use_*booleans) — mutually exclusive with the modern family - Cost filtering — modern family:
filter_expression(object:dimensions|tags|cost_categories|and|or|not, depth-capped at 2),metrics(list, single value) — mutually exclusive with the legacy family; paired via acheckblock, not a variable validation (see Architecture Notes) - Planned limits:
planned_limit—map(object({ amount, unit }))keyed bystart_time - Notification default channel:
sns_topic_arn,notifications—map(object({ comparison_operator?, threshold, threshold_type?, notification_type?, notification_email_addresses?, subscriber_sns_topic_arns? })) - Budget actions (child collection):
budget_actions—map(object({ account_id?, action_type, approval_model?, execution_role_arn, notification_type?, action_threshold, iam_action_definition?, scp_action_definition?, ssm_action_definition?, subscribers?, tags? })) - Universal:
tags,timeouts(applies toaws_budgets_budget_actiononly — the budget resource has notimeoutsblock)
- Primary:
id(budget, compositeAccountID:BudgetName),arn(budget) - Budget attributes:
name,tags_all - Budget actions (maps keyed by the caller's
budget_actionskey):budget_action_ids,budget_action_arns,budget_action_status
ℹ️ No outputs are marked
sensitive— this module emits no secrets.budget_action_statusis a plan-time snapshot; the live approval state is authoritative on the AWS Budgets console.
- ARN / ID formats:
- Budget
id→ compositeAccountID:BudgetName(e.g.123456789012:org-monthly-cost) — matches the import format exactly. - Budget
arn→arn:aws:budgets::<account-id>:budget/<budget-name>(no Region segment — Budgets ARNs are partition/account-scoped only, consistent with AWS's own Budgets IAM policy examples that specify the budget ARN in theResourceelement). - Budget action
id→ compositeAccountID:ActionID:BudgetName— note the action id sits in the middle, unlike the budget's two-segment key. This is a genuinely non-standard import key shape versus the rest of the library. - Budget action
arn— the provider only documents it as "The ARN of the budget action" without publishing an exact segment grammar; treatbudget_action_arns[*]as an opaque cross-reference string for IAM policy conditions rather than something to hand-construct. - Force-new / immutable-in-practice fields:
budget_typeandtime_unitare not markedForceNewin the provider schema, but the Budgets API rejects most attempts to change a budget's fundamental type or cadence via update in practice. Plan to replace (pick a newname) rather than mutate when either must change. tags↔tags_all↔default_tags: confirmed supported on bothaws_budgets_budgetandaws_budgets_budget_actionin provider v6.53.0.var.tagsflows to the budget; each budget action receivesmerge(var.tags, each.value.tags).tags_allis the provider-computed union overdefault_tags, with resource tags winning on key conflict.default_tagsremains the caller's provider-block concern.- Eventual consistency: the first notification to a brand-new SNS topic triggers a subscription-confirmation handshake outside Terraform's control;
DescribeBudgetPerformanceHistoryread-back timing can lag right afterapplyonauto_adjust_data/planned_limitbudgets. - Destroy ordering:
aws_budgets_budget_actionmust be destroyed before or concurrently with its parentaws_budgets_budget. Terraform's implicit dependency graph (budget_name = aws_budgets_budget.this.name) handles this automatically — no explicitdepends_onis present or needed inmain.tf. - The
filter_expression↔metricspairing check is acheckblock, not a variablevalidation, and that changes its failure behavior.filter_expressionrequiresmetricsand vice versa, but avalidationonfilter_expressionthat readsvar.metricscombined with avalidationonmetricsthat readsvar.filter_expressioncreates a variable-validation dependency cycle — Terraform rejects that configuration outright. The bidirectional check instead lives in acheck "filter_expression_requires_metrics"block inmain.tf. The practical consequence: a violatedcheckblock assertion surfaces as a warning, not a hard plan/apply failure — Terraform will still proceed, and if the pairing is genuinely broken the AWS API itself will reject theapplywith its own error. Do not mistake the absence of a hard-stop atplantime for "the check didn't fire." - Budgets is billing-plane, single-partition, and routed to
us-east-1regardless of the caller's provider Region. This is AWS API routing behavior, not a module concern — noregionvariable, no provider alias — but it means the resource shows up in theus-east-1billing console even when every other resource in the stack lives elsewhere. execution_role_arnand the action must be in the same account — cross-account execution roles are rejected at apply.- Import composite keys are non-standard for this module specifically:
AccountID:BudgetNamefor the budget,AccountID:ActionID:BudgetNamefor each action — neither is a plain resource id. See Example 13.
Secure-by-default posture and the explicit opt-out for each:
| Hardened default | Behavior | Opt-out / control |
|---|---|---|
| Notification channel | SNS-only by default (var.sns_topic_arn backs every notifications / budget_actions[*].subscribers entry that doesn't set its own) — alerts integrate with existing paging pipelines instead of relying on inboxes |
notification_email_addresses per notifications entry adds email recipients in addition to SNS; there is no variable that makes email the sole channel |
| Budget action approval | approval_model = "MANUAL" for every action by default — a human reviews and runs the action from the Budget details page rather than AWS auto-attaching a restrictive IAM/SCP policy or stopping instances unattended |
Set approval_model = "AUTOMATIC" explicitly per entry in var.budget_actions — reserve for well-tested, low-blast-radius actions after change-management review (see Example 7's risk callout) |
| SCP budget actions | Schema-supported, but only a management account can execute APPLY_SCP_POLICY successfully — an AWS/Organizations constraint, not a module-level block |
No opt-out needed or offered; the constraint is documented, not enforced in Terraform, because Organizations topology is an environment fact this module cannot see |
| Threshold notification type | Defaults to ACTUAL spend (fires on real spend, not merely forecast) |
Set notification_type = "FORECASTED" per notifications entry |
| Tags | Universal tags on both resources, tags_all emitted on the budget |
N/A — always on |
Other principles: one composite owns the budget and its remediation workflow so callers never hand-wire the budget_name linkage between two module calls; notifications and budget actions are separate map(object(...)) child collections keyed by caller-supplied stable strings (no count); the SNS topic, IAM execution role, IAM policy, and SCP are deliberately out of scope and consumed by reference, keeping this module's blast radius limited to the Budgets API surface itself.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan # requires valid AWS credentials (profile / SSO / OIDC) + a region
terraform apply
terraform output
⚠️ plan/applyrequire valid AWS credentials and a configured region (provider block /AWS_PROFILE/ SSO / OIDC) even though Budgets itself always executes inus-east-1under the hood. Always pin the module source with?ref=v1.0.0, never a branch.
terraform init -backend=false && terraform validate— schema + reference integrity, including the mutual-exclusion and pairingvalidation {}blocks oncost_filter/filter_expression/cost_types/metrics.terraform fmt -check— formatting.terraform planagainst a sandbox account — confirm the budget renders the intended filter family (legacy vs modern) and that everynotifications/budget_actionsentry resolves to at least one subscriber.- Watch for the
check "filter_expression_requires_metrics"warning in plan output if you intentionally test a broken pairing — it will not block the plan (see Architecture Notes), so treat it as an assertion, not a gate. - Verify budget actions: after
apply, confirm each action'sbudget_action_statusisSTANDBYand thatapproval_modelmatches what you intended (MANUALunless explicitly opted out). - Confirm the SNS subscription is confirmed, not merely pending, before relying on alerting in a shared environment.
- Destroy test in a throwaway account — verify the action(s) tear down before/with the budget with no manual intervention.
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
arn = "arn:aws:budgets::123456789012:budget/app-monthly-cost-guardrail"
id = "123456789012:app-monthly-cost-guardrail"
name = "app-monthly-cost-guardrail"
budget_action_ids = { "lock-ec2" = "ab-abc123XYZ456" }
budget_action_arns = { "lock-ec2" = "arn:aws:budgets::123456789012:budgetaction/app-monthly-cost-guardrail/ab-abc123XYZ456" }
budget_action_status = { "lock-ec2" = "STANDBY" }
tags_all = { "Environment" = "prod", "App" = "lending-portal", "CostCenter" = "app-eng" }
ℹ️ The
budget_action_arnsvalue shown is illustrative — the provider documents the attribute only as "The ARN of the budget action" without a published segment grammar; treat the exact string as opaque and always read it from the output rather than constructing it by hand.
notificationsentry fails validation with "must resolve to at least one subscriber channel": Neithervar.sns_topic_arnnor the entry's ownsubscriber_sns_topic_arns/notification_email_addressesis set. Wiresns_topic_arnfromterraform-aws-snsat the module level, or supply per-entry subscribers.budget_actionsentry fails the same way: Same root cause — setvar.sns_topic_arnor a per-actionsubscriberslist.- RI/Savings-Plans budget rejected at
planfor missingcost_types: Expected — a variablevalidationcatches this early forRI_UTILIZATION/RI_COVERAGE/SAVINGS_PLANS_UTILIZATION/SAVINGS_PLANS_COVERAGE, because their settings conflict with the AWS-defaultcost_typesbehavior. Setcost_typesexplicitly (Example 3). filter_expressionset withoutmetrics(or vice versa) — plan succeeds but shows acheckwarning: This is the cross-variable pairing check living in acheckblock instead of a variablevalidation(see Architecture Notes for why). Acheckfailure is a warning, not a hard stop — theapplywill proceed and AWS's own API validation will reject it if the pairing is truly broken. Don't mistake "plan didn't fail" for "the config is valid."AccessDeniedExceptionon anAPPLY_SCP_POLICYaction: Expected outside a management account — only the management account can attach SCPs. This is an AWS Organizations constraint, not an IAM permission gap; re-run from management-account credentials or switch the action toAPPLY_IAM_POLICY/RUN_SSM_DOCUMENTS.- Budget action never executes despite
approval_model = "AUTOMATIC": Confirmexecution_role_arn's trust policy allowsbudgets.amazonaws.comto assume it and that the role has the target permissions (iam:AttachRolePolicy/etc. forAPPLY_IAM_POLICY,organizations:AttachPolicyforAPPLY_SCP_POLICY,ssm:StartAutomationExecution/ec2:StopInstances/rds:StopDBInstanceforRUN_SSM_DOCUMENTS) — a missing execution-role permission fails silently from Terraform's perspective (the action resource still creates) and only surfaces in the action's history/status. iam:PassRoledenial creating/updating a budget action: The Terraform identity is missingiam:PassRolescoped to the specificexecution_role_arn. Never grant this against*.- Notifications created but no alerts arrive: A brand-new SNS topic ARN needs its subscription confirmed out-of-band before alerts flow — check
terraform-aws-sns's subscription outputs forpending_confirmation = true. - Budget doesn't show up where you expected in the console: Budgets is routed to
us-east-1regardless of your provider's configured Region — check theus-east-1billing console, not the Region your other resources live in. - Tag drift / unexpected tags: Caused by
default_tagsoverlap. Resource tags win on key conflict — if a tag value differs from what you set invar.tags/budget_actions[*].tags, adefault_tagsentry is colliding. Set the value explicitly. - Credential-chain failures (
NoCredentialProviders/ExpiredToken): No valid credentials resolved. SetAWS_PROFILE, refresh SSO, or confirm OIDC role assumption in CI. The module never takes credentials as variables. - Renaming a budget destroys and recreates it:
budget_typeandtime_unitare immutable in practice even though the schema doesn't mark themForceNew— plan a newnamerather than expecting an in-place type/cadence change.
- Terraform Registry —
hashicorp/awsprovider:aws_budgets_budget,aws_budgets_budget_action - AWS — Managing your costs with budgets (AWS Budgets User Guide)
- AWS — Configuring budget actions / Setting up a role for AWS Budgets to run budget actions / Reviewing and approving your budget action
- AWS — Service-linked roles for Budgets (
AWSServiceRoleForBudgets) - AWS — Allow AWS Budgets to apply IAM policies and SCPs (example IAM policies for the execution role)
- —
terraform-aws-sns,terraform-aws-iam-role,terraform-aws-iam-policy,terraform-aws-organizations(Phase 7),terraform-aws-cost-management(sibling)
🧡 "Infrastructure as Code should be standardized, consistent, and secure."