IaC Azure

Terraform Module: Azure Subscription Budget — guardrails that page you before the invoice does

Quick take — Reusable hashicorp/azurerm ~> 4.0 module for resource group consumption budgets: tiered percentage thresholds, email + Action Group alerts, dimension/tag filters, and forecasted-spend warnings. 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 "azurerm" {
  features {}
}

module "budget" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-budget?ref=v1.0.0"

  name              = "..."  # Budget name, unique within the resource group scope (1-…
  resource_group_id = "..."  # Full ARM ID of the resource group the budget is scoped …
  amount            = 0      # Budgeted amount per time grain, in the billing currency…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure consumption budget is a cost-tracking object that watches actual and forecasted spend over a time grain (monthly, quarterly, or annually) and fires alerts when spend crosses thresholds you define as a percentage of the budgeted amount. It does not stop resources from running — it is a notification and automation trigger, not a hard cap. That distinction is exactly why it belongs in a module: the value is in consistently wiring the same tiered thresholds, the same Action Group, and the same filters across dozens of resource groups, so no team ships a workload without spend telemetry.

This module wraps azurerm_consumption_budget_resource_group, which scopes the budget to a single resource group (the most common blast-radius boundary in Azure landing zones — one team, one app, one cost owner). Wrapping it pays off because the raw resource has several fiddly, easy-to-get-wrong areas: the notification blocks must use valid operator/threshold_type combinations, time_period.start_date must be the first of a month in RFC 3339 form or apply fails, and forecasted alerts (threshold_type = "Forecasted") behave differently from actual-spend alerts. The module encodes those rules once as validations and sane defaults, so consumers just pass an amount and a list of contacts.

When to use it

Module structure

terraform-module-azure-budget/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_consumption_budget_resource_group + notifications
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, name, amount, thresholds
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
# main.tf
locals {
  # Azure requires the budget start date to be the first day of a month.
  # If no start date is supplied, default to the first of the current month (UTC).
  start_date = coalesce(
    var.start_date,
    formatdate("YYYY-MM-01'T'00:00:00'Z'", timestamp())
  )
}

resource "azurerm_consumption_budget_resource_group" "this" {
  name              = var.name
  resource_group_id = var.resource_group_id

  amount     = var.amount
  time_grain = var.time_grain

  time_period {
    start_date = local.start_date
    end_date   = var.end_date
  }

  # Optional scoping: restrict the budget to specific dimensions and/or tags.
  dynamic "filter" {
    for_each = (length(var.dimension_filters) > 0 || length(var.tag_filters) > 0) ? [1] : []

    content {
      dynamic "dimension" {
        for_each = var.dimension_filters
        content {
          name     = dimension.value.name
          operator = dimension.value.operator
          values   = dimension.value.values
        }
      }

      dynamic "tag" {
        for_each = var.tag_filters
        content {
          name     = tag.value.name
          operator = tag.value.operator
          values   = tag.value.values
        }
      }
    }
  }

  # One notification block per threshold tier.
  dynamic "notification" {
    for_each = { for n in var.notifications : "${n.threshold_type}-${n.threshold}" => n }

    content {
      enabled        = notification.value.enabled
      threshold      = notification.value.threshold
      threshold_type = notification.value.threshold_type
      operator       = notification.value.operator

      contact_emails = notification.value.contact_emails
      contact_roles  = notification.value.contact_roles
      contact_groups = notification.value.contact_groups
    }
  }

  # Budgets can drift in the portal; tag values change often. Avoid
  # spurious diffs by ignoring the auto-extended end_date when omitted.
  lifecycle {
    ignore_changes = [time_period[0].end_date]
  }
}
# variables.tf
variable "name" {
  type        = string
  description = "Name of the consumption budget (unique within the resource group scope)."

  validation {
    condition     = can(regex("^[a-zA-Z0-9_.-]{1,63}$", var.name))
    error_message = "name must be 1-63 chars: letters, numbers, underscore, dot, or hyphen."
  }
}

variable "resource_group_id" {
  type        = string
  description = "Full ARM resource ID of the resource group the budget is scoped to."

  validation {
    condition     = can(regex("^/subscriptions/[^/]+/resourceGroups/[^/]+$", var.resource_group_id))
    error_message = "resource_group_id must be a full /subscriptions/<id>/resourceGroups/<name> ARM ID."
  }
}

variable "amount" {
  type        = number
  description = "Total budgeted amount for the time grain, in the billing account currency."

  validation {
    condition     = var.amount > 0
    error_message = "amount must be greater than 0."
  }
}

variable "time_grain" {
  type        = string
  default     = "Monthly"
  description = "Reset cadence: Monthly, Quarterly, Annually (also BillingMonth/BillingQuarter/BillingAnnual)."

  validation {
    condition = contains(
      ["Monthly", "Quarterly", "Annually", "BillingMonth", "BillingQuarter", "BillingAnnual"],
      var.time_grain
    )
    error_message = "time_grain must be one of Monthly, Quarterly, Annually, BillingMonth, BillingQuarter, BillingAnnual."
  }
}

variable "start_date" {
  type        = string
  default     = null
  description = "RFC 3339 start date; MUST be the first day of a month (e.g. 2026-07-01T00:00:00Z). Defaults to the first of the current month."

  validation {
    condition     = var.start_date == null || can(regex("^[0-9]{4}-[0-9]{2}-01T00:00:00Z$", coalesce(var.start_date, "0000-00-01T00:00:00Z")))
    error_message = "start_date must be the first of a month in the form YYYY-MM-01T00:00:00Z."
  }
}

variable "end_date" {
  type        = string
  default     = null
  description = "Optional RFC 3339 end date. Omit to let Azure default ~10 years out."
}

variable "notifications" {
  type = list(object({
    threshold      = number
    threshold_type = optional(string, "Actual")
    operator       = optional(string, "GreaterThanOrEqualTo")
    enabled        = optional(bool, true)
    contact_emails = optional(list(string), [])
    contact_roles  = optional(list(string), [])
    contact_groups = optional(list(string), [])
  }))
  description = "Alert tiers. threshold is a percentage of amount (0-1000). threshold_type is Actual or Forecasted."

  default = [
    { threshold = 50, threshold_type = "Actual" },
    { threshold = 90, threshold_type = "Actual" },
    { threshold = 100, threshold_type = "Forecasted" },
  ]

  validation {
    condition     = length(var.notifications) > 0 && length(var.notifications) <= 5
    error_message = "Provide between 1 and 5 notification tiers (Azure caps a budget at 5)."
  }

  validation {
    condition     = alltrue([for n in var.notifications : n.threshold > 0 && n.threshold <= 1000])
    error_message = "Each notification threshold must be a percentage in the range (0, 1000]."
  }

  validation {
    condition     = alltrue([for n in var.notifications : contains(["Actual", "Forecasted"], n.threshold_type)])
    error_message = "threshold_type must be either Actual or Forecasted."
  }

  validation {
    condition = alltrue([
      for n in var.notifications :
      length(n.contact_emails) + length(n.contact_roles) + length(n.contact_groups) > 0
    ])
    error_message = "Each notification needs at least one of contact_emails, contact_roles, or contact_groups."
  }
}

variable "dimension_filters" {
  type = list(object({
    name     = string
    operator = optional(string, "In")
    values   = list(string)
  }))
  default     = []
  description = "Optional dimension filters, e.g. name = \"ResourceType\" or \"MeterCategory\" with a list of values."
}

variable "tag_filters" {
  type = list(object({
    name     = string
    operator = optional(string, "In")
    values   = list(string)
  }))
  default     = []
  description = "Optional cost-allocation tag filters, e.g. name = \"Environment\", values = [\"Production\"]."
}
# outputs.tf
output "id" {
  description = "ARM resource ID of the consumption budget."
  value       = azurerm_consumption_budget_resource_group.this.id
}

output "name" {
  description = "Name of the consumption budget."
  value       = azurerm_consumption_budget_resource_group.this.name
}

output "resource_group_id" {
  description = "Resource group the budget is scoped to."
  value       = azurerm_consumption_budget_resource_group.this.resource_group_id
}

output "amount" {
  description = "Budgeted amount applied per time grain."
  value       = azurerm_consumption_budget_resource_group.this.amount
}

output "time_grain" {
  description = "Reset cadence of the budget."
  value       = azurerm_consumption_budget_resource_group.this.time_grain
}

output "thresholds" {
  description = "Map of configured alert tiers keyed by \"<type>-<threshold>\"."
  value = {
    for n in var.notifications :
    "${n.threshold_type}-${n.threshold}" => {
      threshold      = n.threshold
      threshold_type = n.threshold_type
      enabled        = n.enabled
    }
  }
}

How to use it

data "azurerm_subscription" "current" {}

resource "azurerm_resource_group" "app" {
  name     = "rg-payments-prod"
  location = "centralindia"
  tags = {
    Environment = "Production"
    CostCenter  = "FIN-1042"
  }
}

# Centralized alert routing reused by every budget in the platform.
resource "azurerm_monitor_action_group" "finops" {
  name                = "ag-finops-alerts"
  resource_group_name = azurerm_resource_group.app.name
  short_name          = "finops"

  email_receiver {
    name          = "finops-dl"
    email_address = "finops@kloudvin.io"
  }

  webhook_receiver {
    name        = "teams-webhook"
    service_uri = "https://kloudvin.webhook.office.com/webhookb2/incoming"
  }
}

module "subscription_budget" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-budget?ref=v1.0.0"

  name              = "bdgt-payments-prod"
  resource_group_id = azurerm_resource_group.app.id
  amount            = 1800 # INR/month run-rate ceiling for this workload
  time_grain        = "Monthly"
  start_date        = "2026-07-01T00:00:00Z"

  # Only count Production-tagged spend in this RG.
  tag_filters = [
    {
      name   = "Environment"
      values = ["Production"]
    }
  ]

  notifications = [
    {
      threshold      = 50
      threshold_type = "Actual"
      contact_emails = ["payments-team@kloudvin.io"]
    },
    {
      threshold      = 90
      threshold_type = "Actual"
      contact_groups = [azurerm_monitor_action_group.finops.id]
      contact_roles  = ["Owner"]
    },
    {
      threshold      = 100
      threshold_type = "Forecasted" # fires when run-rate projects an overrun
      contact_groups = [azurerm_monitor_action_group.finops.id]
    },
  ]
}

# Downstream reference: surface the budget ID into a platform inventory
# locals map, or feed it to a compliance dashboard.
output "payments_budget_id" {
  value = module.subscription_budget.id
}

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 = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm 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-azure-budget?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_id = "..."
  amount = 0
}

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 Yes Budget name, unique within the resource group scope (1-63 chars).
resource_group_id string Yes Full ARM ID of the resource group the budget is scoped to.
amount number Yes Budgeted amount per time grain, in the billing currency. Must be > 0.
time_grain string "Monthly" No Reset cadence: Monthly, Quarterly, Annually, BillingMonth, BillingQuarter, BillingAnnual.
start_date string null No RFC 3339 start; must be the first of a month. Defaults to the first of the current month.
end_date string null No Optional RFC 3339 end date; Azure defaults ~10 years out if omitted.
notifications list(object) 50%/90% Actual + 100% Forecasted No 1-5 alert tiers; each needs a threshold (0-1000%) and at least one contact.
dimension_filters list(object) [] No Dimension filters (e.g. ResourceType, MeterCategory) with operator + values.
tag_filters list(object) [] No Cost-allocation tag filters (e.g. Environment = ["Production"]).

Outputs

Name Description
id ARM resource ID of the consumption budget.
name Name of the consumption budget.
resource_group_id Resource group the budget is scoped to.
amount Budgeted amount applied per time grain.
time_grain Reset cadence of the budget.
thresholds Map of configured alert tiers keyed by "<type>-<threshold>".

Enterprise scenario

A retail bank’s platform team runs an Azure landing zone with ~140 application resource groups, each owned by a different product squad. Their for_each root module iterates a YAML catalog and instantiates this module once per RG, pulling each squad’s monthly ceiling and distribution list from the catalog while sharing one central ag-finops-alerts Action Group. When a squad’s spend hits 90% of its ceiling the squad’s on-call and the Owner role are emailed; the forecasted 100% tier gives FinOps a two-week head start to investigate run-rate spikes (usually a forgotten GPU VM or an orphaned premium disk) before the actual invoice lands.

Best practices

TerraformAzureSubscription BudgetModuleIaC
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