Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS Budgets Terraform Module

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.

Terraform aws module type resources


🧩 Overview

  • 💰 Provisions an aws_budgets_budget keystone — cost, usage, Savings Plans utilization/coverage, or RI utilization/coverage — supporting both the legacy cost_filter/cost_types family and the modern filter_expression/metrics family (mutually exclusive; pick one).
  • 🔔 SNS-first notifications. Every notifications entry defaults to var.sns_topic_arn so 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_action child collection (for_each over map(object(...))) applies an IAM policy, attaches an SCP, or stops EC2/RDS instances when a threshold fires — every action defaults approval_model = "MANUAL" so a human reviews before anything is attached or stopped.
  • 🧮 Ramp and adjust. Optional planned_limit map for a phased/seasonal spend schedule, and auto_adjust_data for a FORECAST- or HISTORICAL-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.tags flows to the budget and merges per-action (merge(var.tags, each.value.tags)) on every budget action; tags_all surfaced as an output on the budget.
  • 🌎 No region variable, no us-east-1 alias. Budgets is a billing-plane API auto-routed to us-east-1 regardless of the caller's configured Region — different from CloudFront/ACM/WAFv2, which need an explicit providers = { 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 Deny policy, 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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

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
Loading

🧬 What this module builds

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
Loading
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 dynamic blocks over caller-supplied variables (cost_filter, cost_types, filter_expression, planned_limit, auto_adjust_data, notification on the budget; iam_action_definition / scp_action_definition / ssm_action_definition / subscriber / timeouts on 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.


✅ Provider / Versions

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).


🔑 Required IAM Permissions

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 AWSServiceRoleForBudgets on ordinary same-account cost/usage budget creation — only when a shared cross-account billing view is referenced. The Terraform identity does not need iam:CreateServiceLinkedRole for the module's default posture.


📋 AWS Prerequisites

  • Account scoping is broader than the "management account only" assumption. Both aws_budgets_budget and aws_budgets_budget_action expose an optional account_id that 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_POLICY actions. AWS's own budget-action configuration guidance states plainly: "Only a management account can apply SCPs." APPLY_IAM_POLICY and RUN_SSM_DOCUMENTS actions run in any account, member or management — do not generalize the SCP restriction to budgets or to other action types.
  • Service-linked role AWSServiceRoleForBudgets is created automatically the first time CreateBudget/UpdateBudget references a billing_view_arn shared from another account (it grants Budgets billing:GetBillingViewData on 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_arn must be an IAM role in the same account as the action, trusted by budgets.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 from terraform-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 or DescribeBudgetActionHistories.
  • SNS delivery confirmation. The first notification to a brand-new subscriber_sns_topic_arns value 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_budget and aws_budgets_budget_action are billing-plane resources — AWS routes Budgets API calls to us-east-1 automatically regardless of the caller's configured Region. No providers = { aws = aws.us_east_1 } pattern is needed or supported in this module (unlike terraform-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).

📁 Module Structure

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

⚙️ Quick Start

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. Wire sns_topic_arn from terraform-aws-sns to activate the secure-by-default alerting channel; without it every notifications / budget_actions entry must supply its own subscribers or the plan fails validation.


🔌 Cross-Module Contract

Consumes

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

Emits

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

📚 Example Library

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" }
}

📥 Inputs

ℹ️ 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 11 include_*/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 a check block, not a variable validation (see Architecture Notes)
  • Planned limits: planned_limitmap(object({ amount, unit })) keyed by start_time
  • Notification default channel: sns_topic_arn, notificationsmap(object({ comparison_operator?, threshold, threshold_type?, notification_type?, notification_email_addresses?, subscriber_sns_topic_arns? }))
  • Budget actions (child collection): budget_actionsmap(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 to aws_budgets_budget_action only — the budget resource has no timeouts block)

🧾 Outputs

  • Primary: id (budget, composite AccountID:BudgetName), arn (budget)
  • Budget attributes: name, tags_all
  • Budget actions (maps keyed by the caller's budget_actions key): budget_action_ids, budget_action_arns, budget_action_status

ℹ️ No outputs are marked sensitive — this module emits no secrets. budget_action_status is a plan-time snapshot; the live approval state is authoritative on the AWS Budgets console.


🧠 Architecture Notes

  • ARN / ID formats:
  • Budget id → composite AccountID:BudgetName (e.g. 123456789012:org-monthly-cost) — matches the import format exactly.
  • Budget arnarn: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 the Resource element).
  • Budget action id → composite AccountID: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; treat budget_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_type and time_unit are not marked ForceNew in 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 new name) rather than mutate when either must change.
  • tagstags_alldefault_tags: confirmed supported on both aws_budgets_budget and aws_budgets_budget_action in provider v6.53.0. var.tags flows to the budget; each budget action receives merge(var.tags, each.value.tags). tags_all is the provider-computed union over default_tags, with resource tags winning on key conflict. default_tags remains 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; DescribeBudgetPerformanceHistory read-back timing can lag right after apply on auto_adjust_data / planned_limit budgets.
  • Destroy ordering: aws_budgets_budget_action must be destroyed before or concurrently with its parent aws_budgets_budget. Terraform's implicit dependency graph (budget_name = aws_budgets_budget.this.name) handles this automatically — no explicit depends_on is present or needed in main.tf.
  • The filter_expressionmetrics pairing check is a check block, not a variable validation, and that changes its failure behavior. filter_expression requires metrics and vice versa, but a validation on filter_expression that reads var.metrics combined with a validation on metrics that reads var.filter_expression creates a variable-validation dependency cycle — Terraform rejects that configuration outright. The bidirectional check instead lives in a check "filter_expression_requires_metrics" block in main.tf. The practical consequence: a violated check block 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 the apply with its own error. Do not mistake the absence of a hard-stop at plan time for "the check didn't fire."
  • Budgets is billing-plane, single-partition, and routed to us-east-1 regardless of the caller's provider Region. This is AWS API routing behavior, not a module concern — no region variable, no provider alias — but it means the resource shows up in the us-east-1 billing console even when every other resource in the stack lives elsewhere.
  • execution_role_arn and 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:BudgetName for the budget, AccountID:ActionID:BudgetName for each action — neither is a plain resource id. See Example 13.

🧱 Design Principles

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.


🚀 Runbook

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 / apply require valid AWS credentials and a configured region (provider block / AWS_PROFILE / SSO / OIDC) even though Budgets itself always executes in us-east-1 under the hood. Always pin the module source with ?ref=v1.0.0, never a branch.


🧪 Testing

  • terraform init -backend=false && terraform validate — schema + reference integrity, including the mutual-exclusion and pairing validation {} blocks on cost_filter/filter_expression/cost_types/metrics.
  • terraform fmt -check — formatting.
  • terraform plan against a sandbox account — confirm the budget renders the intended filter family (legacy vs modern) and that every notifications / budget_actions entry 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's budget_action_status is STANDBY and that approval_model matches what you intended (MANUAL unless 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.

💬 Example Output

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_arns value 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.


🔍 Troubleshooting

  • notifications entry fails validation with "must resolve to at least one subscriber channel": Neither var.sns_topic_arn nor the entry's own subscriber_sns_topic_arns / notification_email_addresses is set. Wire sns_topic_arn from terraform-aws-sns at the module level, or supply per-entry subscribers.
  • budget_actions entry fails the same way: Same root cause — set var.sns_topic_arn or a per-action subscribers list.
  • RI/Savings-Plans budget rejected at plan for missing cost_types: Expected — a variable validation catches this early for RI_UTILIZATION / RI_COVERAGE / SAVINGS_PLANS_UTILIZATION / SAVINGS_PLANS_COVERAGE, because their settings conflict with the AWS-default cost_types behavior. Set cost_types explicitly (Example 3).
  • filter_expression set without metrics (or vice versa) — plan succeeds but shows a check warning: This is the cross-variable pairing check living in a check block instead of a variable validation (see Architecture Notes for why). A check failure is a warning, not a hard stop — the apply will 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."
  • AccessDeniedException on an APPLY_SCP_POLICY action: 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 to APPLY_IAM_POLICY / RUN_SSM_DOCUMENTS.
  • Budget action never executes despite approval_model = "AUTOMATIC": Confirm execution_role_arn's trust policy allows budgets.amazonaws.com to assume it and that the role has the target permissions (iam:AttachRolePolicy/etc. for APPLY_IAM_POLICY, organizations:AttachPolicy for APPLY_SCP_POLICY, ssm:StartAutomationExecution/ec2:StopInstances/rds:StopDBInstance for RUN_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:PassRole denial creating/updating a budget action: The Terraform identity is missing iam:PassRole scoped to the specific execution_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 for pending_confirmation = true.
  • Budget doesn't show up where you expected in the console: Budgets is routed to us-east-1 regardless of your provider's configured Region — check the us-east-1 billing console, not the Region your other resources live in.
  • Tag drift / unexpected tags: Caused by default_tags overlap. Resource tags win on key conflict — if a tag value differs from what you set in var.tags / budget_actions[*].tags, a default_tags entry is colliding. Set the value explicitly.
  • Credential-chain failures (NoCredentialProviders / ExpiredToken): No valid credentials resolved. Set AWS_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_type and time_unit are immutable in practice even though the schema doesn't mark them ForceNew — plan a new name rather than expecting an in-place type/cadence change.

🔗 Related Docs

  • Terraform Registry — hashicorp/aws provider: 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."

Releases

Packages

Contributors

Languages