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
- You run a landing zone where every application resource group must have a budget before go-live, and you want that enforced as code rather than as a wiki page nobody reads.
- You want tiered escalation — e.g. an FYI email at 50% of actual spend, a Teams/PagerDuty ping at 90%, and a forecasted 100% warning that fires mid-month when the run-rate projects an overrun.
- You need budgets to filter by tag or meter (e.g. only
Environment = Productionresources, or only virtual-machine meters) instead of the whole resource group total. - You centralize alert routing through an Action Group (email, SMS, webhook, Logic App, Automation Runbook) and want every budget to reuse it.
- Reach for
azurerm_consumption_budget_subscriptioninstead if your cost boundary is the whole subscription; use this resource-group-scoped module when ownership is per-RG.
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 config — live/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 config — live/prod/budget/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-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
- Lead with a Forecasted tier, not just Actual. A
Forecasted100% notification fires mid-cycle when the projected run-rate will breach the budget, giving you days to react instead of a post-mortem after billing closes — Actual-only budgets always alert too late. - Route through an Action Group, not raw emails. Pass
contact_groupsso escalation logic (SMS, Teams, Logic App auto-shutdown of non-prod) lives in one reusableazurerm_monitor_action_group; reservecontact_emails/contact_rolesfor low-tier FYI alerts. - Pin the start_date to the first of a month explicitly in production. Relying on the
timestamp()default makes plans non-deterministic across months; setstart_date = "YYYY-MM-01T00:00:00Z"so re-runs don’t produce surprise diffs. - Filter by tag to keep budgets accountable. Scope budgets with
tag_filters(e.g.Environment = Production) so a shared RG’s dev and prod spend are tracked separately and a noisy non-prod experiment can’t silently consume the production allowance. - Name budgets by scope, not by amount. Use
bdgt-<workload>-<env>so a budget rename isn’t required every time finance revises the ceiling; theamountis data, thenameis identity. - Remember it is a guardrail, not a gate. Budgets never stop spend — pair high-tier alerts with an Action Group that triggers an Automation Runbook or Logic App if you need actual enforcement (e.g. deallocating non-production compute) rather than assuming the budget caps cost.