Quick take — A reusable hashicorp/google Terraform module for google_billing_budget: tiered threshold alerts, Pub/Sub automation hooks, and credit-aware filters scoped to projects, services, and labels. 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 "google" {
project = "my-project"
region = "us-central1"
}
module "budget" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-budget?ref=v1.0.0"
billing_account = "..." # Cloud Billing account ID (`XXXXXX-XXXXXX-XXXXXX`) the b…
display_name = "..." # Budget name shown in the console (1–60 chars).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A GCP Billing Budget (google_billing_budget) is a guardrail attached to a Cloud Billing account — not to a project. It defines a spend amount (either a fixed currency figure or “last month’s actual spend”), one or more threshold rules that fire at percentages of that amount, and an optional filter that narrows what counts toward the budget (specific projects, services, SKUs, labels, or credit types). When a threshold is crossed, GCP can email the billing admins, ping an arbitrary set of monitoring channels, and — most usefully for automation — publish a JSON message to a Pub/Sub topic that a Cloud Function or workflow can act on (e.g. throttle a runaway pipeline or disable billing on a sandbox).
Wrapping it in a module matters because the raw resource has three sharp edges that teams trip over repeatedly: the budget_filter block is fiddly (project IDs must be fully-qualified projects/{id} strings, services use the services/{ID} API path, and credit_types only applies when credit_types_treatment = "INCLUDE_SPECIFIED_CREDITS"); thresholds are a list where each entry is a separate alert and “current vs forecasted” is a per-threshold choice; and the Pub/Sub plumbing needs an explicit IAM grant to the Cloud Billing service agent or notifications silently fail. A module bakes those rules in once, validates the inputs, and lets every team stamp out a correct budget with three or four variables.
When to use it
- Per-project cost guardrails at scale. You run a landing zone with dozens of projects and want each one to carry a budget that alerts the owning team — module-per-project keeps the config DRY.
- Sandbox / non-prod kill switches. Pair a low budget with a Pub/Sub topic and a Cloud Function that calls the Billing API to disable billing on the project once 100 % is hit, capping blast radius on dev accounts.
- Service- or label-scoped budgets. You need to track only BigQuery spend, or only resources tagged
cost-center: marketing, separately from the project total. - FinOps tiered alerting. Finance wants a 50 % heads-up, an 80 % warning to engineering, and a 100 %+ forecast alarm to leadership — three thresholds, different audiences.
- Skip it for a single throwaway project where the Console’s manual budget wizard is faster, or when you need hard spend enforcement (GCP budgets are alert-and-automate, not a hard cap — the only true stop is automation that disables billing).
Module structure
terraform-module-gcp-budget/
├── versions.tf # provider + Terraform version constraints
├── main.tf # google_billing_budget, dynamic thresholds & filter
├── variables.tf # billing account, amount, thresholds, filter inputs
└── outputs.tf # budget id/name + resolved amount
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# google_billing_budget wants fully-qualified resource names.
# Accept bare project IDs from callers and normalise them here.
qualified_projects = [
for p in var.included_projects :
startswith(p, "projects/") ? p : "projects/${p}"
]
# Pub/Sub topic is only attached when both a topic and the
# all_updates_rule are wanted; emails are governed separately.
enable_pubsub = var.pubsub_topic != null
}
resource "google_billing_budget" "this" {
billing_account = var.billing_account
display_name = var.display_name
# ---- What the budget tracks -------------------------------------------
budget_filter {
# When projects is empty the budget covers the whole billing account.
projects = length(local.qualified_projects) > 0 ? local.qualified_projects : null
# Restrict to a calendar period (MONTH/QUARTER/YEAR) OR a custom range,
# never both. calendar_period is the common case.
calendar_period = var.custom_period == null ? var.calendar_period : null
dynamic "custom_period" {
for_each = var.custom_period != null ? [var.custom_period] : []
content {
start_date {
year = custom_period.value.start.year
month = custom_period.value.start.month
day = custom_period.value.start.day
}
dynamic "end_date" {
for_each = custom_period.value.end != null ? [custom_period.value.end] : []
content {
year = end_date.value.year
month = end_date.value.month
day = end_date.value.day
}
}
}
}
# services use the Cloud Billing Catalog path, e.g.
# "services/24E6-581D-38E5" (Compute Engine).
services = length(var.included_services) > 0 ? var.included_services : null
# Match resources carrying these labels (single value per key in v5).
labels = var.included_labels
# Credit handling: by default GCP nets all credits out of spend.
# Set to EXCLUDE_ALL_CREDITS to budget on gross (list-price) cost,
# or INCLUDE_SPECIFIED_CREDITS to count only certain credit types.
credit_types_treatment = var.credit_types_treatment
credit_types = (
var.credit_types_treatment == "INCLUDE_SPECIFIED_CREDITS"
? var.credit_types
: null
)
}
# ---- How much --------------------------------------------------------
amount {
# Exactly one of specified_amount / last_period_amount applies.
dynamic "specified_amount" {
for_each = var.use_last_period_amount ? [] : [1]
content {
currency_code = var.currency_code
units = tostring(var.amount_units)
}
}
dynamic "last_period_amount" {
for_each = var.use_last_period_amount ? [1] : []
content {}
}
}
# ---- When to alert ---------------------------------------------------
dynamic "threshold_rules" {
for_each = var.threshold_rules
content {
threshold_percent = threshold_rules.value.percent
# CURRENT_SPEND fires on actuals; FORECASTED_SPEND on projected
# end-of-period spend (only valid with calendar_period).
spend_basis = threshold_rules.value.basis
}
}
# ---- Where the alert goes -------------------------------------------
all_updates_rule {
# Cloud Monitoring notification channels (projects/*/notificationChannels/*).
monitoring_notification_channels = var.monitoring_notification_channels
# Also notify the billing account's IAM admins & users by email.
disable_default_iam_recipients = var.disable_default_iam_recipients
pubsub_topic = local.enable_pubsub ? var.pubsub_topic : null
schema_version = local.enable_pubsub ? "1.0" : null
}
}
variables.tf
variable "billing_account" {
description = "Cloud Billing account ID the budget attaches to (e.g. 012345-6789AB-CDEF01). Not a project ID."
type = string
validation {
condition = can(regex("^[A-Z0-9]{6}-[A-Z0-9]{6}-[A-Z0-9]{6}$", var.billing_account))
error_message = "billing_account must be in the form XXXXXX-XXXXXX-XXXXXX (uppercase hex, dash-separated)."
}
}
variable "display_name" {
description = "Human-readable name for the budget, shown in the Billing console."
type = string
validation {
condition = length(var.display_name) > 0 && length(var.display_name) <= 60
error_message = "display_name must be 1-60 characters."
}
}
variable "amount_units" {
description = "Whole-currency budget amount (no decimals). Ignored when use_last_period_amount = true."
type = number
default = 1000
validation {
condition = var.amount_units >= 0
error_message = "amount_units must be zero or positive."
}
}
variable "currency_code" {
description = "ISO 4217 currency for the specified amount. Must match the billing account's currency."
type = string
default = "USD"
validation {
condition = can(regex("^[A-Z]{3}$", var.currency_code))
error_message = "currency_code must be a 3-letter ISO 4217 code, e.g. USD, EUR, INR."
}
}
variable "use_last_period_amount" {
description = "If true, budget targets the previous calendar period's actual spend instead of a fixed amount."
type = bool
default = false
}
variable "calendar_period" {
description = "Recurring window the budget resets on: MONTH, QUARTER, or YEAR."
type = string
default = "MONTH"
validation {
condition = contains(["MONTH", "QUARTER", "YEAR"], var.calendar_period)
error_message = "calendar_period must be one of MONTH, QUARTER, YEAR."
}
}
variable "custom_period" {
description = <<-EOT
Optional fixed date range (overrides calendar_period). Shape:
{
start = { year = 2026, month = 1, day = 1 }
end = { year = 2026, month = 12, day = 31 } # end is optional
}
Note: spend_basis FORECASTED_SPEND is not valid with a custom_period.
EOT
type = object({
start = object({
year = number
month = number
day = number
})
end = optional(object({
year = number
month = number
day = number
}))
})
default = null
}
variable "threshold_rules" {
description = "Alert thresholds. Each entry is a separate notification at percent of the budget."
type = list(object({
percent = number
basis = optional(string, "CURRENT_SPEND")
}))
default = [
{ percent = 0.5, basis = "CURRENT_SPEND" },
{ percent = 0.9, basis = "CURRENT_SPEND" },
{ percent = 1.0, basis = "CURRENT_SPEND" },
{ percent = 1.0, basis = "FORECASTED_SPEND" },
]
validation {
condition = length(var.threshold_rules) > 0
error_message = "At least one threshold rule is required."
}
validation {
condition = alltrue([
for t in var.threshold_rules : t.percent > 0 && t.percent <= 10
])
error_message = "Each threshold percent must be a ratio in (0, 10] (0.5 = 50%, 1.0 = 100%)."
}
validation {
condition = alltrue([
for t in var.threshold_rules :
contains(["CURRENT_SPEND", "FORECASTED_SPEND"], t.basis)
])
error_message = "threshold basis must be CURRENT_SPEND or FORECASTED_SPEND."
}
}
variable "included_projects" {
description = "Project IDs the budget is scoped to. Empty = whole billing account. Bare IDs are auto-prefixed with projects/."
type = list(string)
default = []
}
variable "included_services" {
description = "Cloud Billing service IDs to restrict the budget to, e.g. [\"services/24E6-581D-38E5\"] for Compute Engine. Empty = all services."
type = list(string)
default = []
}
variable "included_labels" {
description = "Map of label key to a single allowed value; only matching resources count toward the budget."
type = map(string)
default = {}
}
variable "credit_types_treatment" {
description = "How credits affect tracked spend: INCLUDE_ALL_CREDITS, EXCLUDE_ALL_CREDITS, or INCLUDE_SPECIFIED_CREDITS."
type = string
default = "INCLUDE_ALL_CREDITS"
validation {
condition = contains(
["INCLUDE_ALL_CREDITS", "EXCLUDE_ALL_CREDITS", "INCLUDE_SPECIFIED_CREDITS"],
var.credit_types_treatment
)
error_message = "credit_types_treatment must be INCLUDE_ALL_CREDITS, EXCLUDE_ALL_CREDITS, or INCLUDE_SPECIFIED_CREDITS."
}
}
variable "credit_types" {
description = "Credit types to include; only used when credit_types_treatment = INCLUDE_SPECIFIED_CREDITS. E.g. [\"COMMITTED_USAGE_DISCOUNT\", \"FREE_TIER\"]."
type = list(string)
default = []
}
variable "monitoring_notification_channels" {
description = "Cloud Monitoring notification channel IDs to alert (projects/{project}/notificationChannels/{id}). Max 5."
type = list(string)
default = []
validation {
condition = length(var.monitoring_notification_channels) <= 5
error_message = "A budget supports at most 5 monitoring notification channels."
}
}
variable "disable_default_iam_recipients" {
description = "If true, suppress the default email to billing account admins/users (rely on channels/Pub/Sub instead)."
type = bool
default = false
}
variable "pubsub_topic" {
description = "Optional Pub/Sub topic for programmatic budget notifications (projects/{project}/topics/{topic}). The Cloud Billing service agent needs pubsub.publisher on it."
type = string
default = null
validation {
condition = var.pubsub_topic == null || can(
regex("^projects/[^/]+/topics/[^/]+$", var.pubsub_topic)
)
error_message = "pubsub_topic must be of the form projects/{project}/topics/{topic}."
}
}
outputs.tf
output "budget_id" {
description = "Terraform resource ID of the budget (billingAccounts/{acct}/budgets/{uuid})."
value = google_billing_budget.this.id
}
output "budget_name" {
description = "Server-assigned resource name of the budget, ending in the generated UUID."
value = google_billing_budget.this.name
}
output "display_name" {
description = "The configured display name of the budget."
value = google_billing_budget.this.display_name
}
output "pubsub_topic" {
description = "Pub/Sub topic wired to the budget, or null if none. Useful to grant the billing service agent publisher rights downstream."
value = google_billing_budget.this.all_updates_rule[0].pubsub_topic
}
output "threshold_percents" {
description = "Sorted list of configured threshold ratios, for assertions or documentation."
value = sort([for t in var.threshold_rules : t.percent])
}
How to use it
A monthly budget on a single production project, scoped to gross spend (so committed-use discounts don’t mask the real list-price burn), with a Pub/Sub hook for automation and a follow-up IAM grant so the Cloud Billing service agent can actually publish:
module "billing_budget" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-budget?ref=v1.0.0"
billing_account = "012345-6789AB-CDEF01"
display_name = "prod-payments — monthly"
amount_units = 25000
currency_code = "USD"
calendar_period = "MONTH"
included_projects = ["payments-prod"]
# Budget on list price, not net-of-CUD, so a discount can't hide overspend.
credit_types_treatment = "EXCLUDE_ALL_CREDITS"
threshold_rules = [
{ percent = 0.5, basis = "CURRENT_SPEND" },
{ percent = 0.8, basis = "CURRENT_SPEND" },
{ percent = 1.0, basis = "CURRENT_SPEND" },
{ percent = 1.2, basis = "FORECASTED_SPEND" },
]
monitoring_notification_channels = [
google_monitoring_notification_channel.finops_email.id,
]
pubsub_topic = google_pubsub_topic.budget_alerts.id
}
# The Cloud Billing service agent must be allowed to publish to the topic,
# otherwise Pub/Sub notifications silently never arrive.
data "google_project" "billing_host" {
project_id = "payments-prod"
}
resource "google_pubsub_topic_iam_member" "budget_publisher" {
topic = google_pubsub_topic.budget_alerts.id
role = "roles/pubsub.publisher"
member = "serviceAccount:billing-budgets@system.gserviceaccount.com"
}
# Downstream: surface the budget's resource name in a label/output for audit.
output "payments_budget_name" {
value = module.billing_budget.budget_name
}
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 = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs 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-gcp-budget?ref=v1.0.0"
}
inputs = {
billing_account = "..."
display_name = "..."
}
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 |
|---|---|---|---|---|
billing_account |
string |
— | Yes | Cloud Billing account ID (XXXXXX-XXXXXX-XXXXXX) the budget attaches to. |
display_name |
string |
— | Yes | Budget name shown in the console (1–60 chars). |
amount_units |
number |
1000 |
No | Whole-currency budget amount; ignored when use_last_period_amount = true. |
currency_code |
string |
"USD" |
No | ISO 4217 currency; must match the billing account currency. |
use_last_period_amount |
bool |
false |
No | Track previous period’s actual spend instead of a fixed amount. |
calendar_period |
string |
"MONTH" |
No | Reset window: MONTH, QUARTER, or YEAR. |
custom_period |
object |
null |
No | Fixed date range overriding calendar_period; end is optional. |
threshold_rules |
list(object) |
50/90/100/100-fcst | No | Alert thresholds; each percent (ratio) + basis (CURRENT_SPEND/FORECASTED_SPEND). |
included_projects |
list(string) |
[] |
No | Project IDs to scope to; empty = whole account. Bare IDs auto-prefixed. |
included_services |
list(string) |
[] |
No | Cloud Billing service IDs (services/{ID}) to restrict to. |
included_labels |
map(string) |
{} |
No | Label key→value pairs resources must carry to count. |
credit_types_treatment |
string |
"INCLUDE_ALL_CREDITS" |
No | INCLUDE_ALL_CREDITS, EXCLUDE_ALL_CREDITS, or INCLUDE_SPECIFIED_CREDITS. |
credit_types |
list(string) |
[] |
No | Credit types to count; only with INCLUDE_SPECIFIED_CREDITS. |
monitoring_notification_channels |
list(string) |
[] |
No | Cloud Monitoring channel IDs to alert (max 5). |
disable_default_iam_recipients |
bool |
false |
No | Suppress default email to billing admins/users. |
pubsub_topic |
string |
null |
No | Pub/Sub topic (projects/{p}/topics/{t}) for programmatic notifications. |
Outputs
| Name | Description |
|---|---|
budget_id |
Terraform resource ID (billingAccounts/{acct}/budgets/{uuid}). |
budget_name |
Server-assigned resource name ending in the generated UUID. |
display_name |
The configured display name of the budget. |
pubsub_topic |
Pub/Sub topic wired to the budget, or null if none. |
threshold_percents |
Sorted list of configured threshold ratios. |
Enterprise scenario
A retail platform team runs a 40-project landing zone on a single billing account. They instantiate this module once per project in a for_each loop, feeding each its own amount_units from a YAML cost-plan and routing alerts to the owning team’s PagerDuty notification channel. Every sandbox project additionally sets pubsub_topic to a shared budget-killswitch topic whose Cloud Function disables billing the moment costAmount >= budgetAmount, so a forgotten GPU VM in dev can never burn more than its monthly cap — while production projects keep the same thresholds purely as FinOps alerts.
Best practices
- Grant the billing service agent
pubsub.publisherexplicitly. GCP does not auto-wire it; withoutroles/pubsub.publisheronserviceAccount:billing-budgets@system.gserviceaccount.com, Pub/Sub notifications are dropped silently and your automation never fires. - Add a
FORECASTED_SPENDthreshold above 100 %. Actual-spend alerts tell you after the money is gone; a forecast threshold (e.g. 120 %) warns you mid-month that you’re trending over, while you can still act. Forecast basis only works withcalendar_period, notcustom_period. - Be deliberate about credits. The default
INCLUDE_ALL_CREDITSnets out CUDs, free-tier, and promotions — so a generous discount can mask runaway gross usage. UseEXCLUDE_ALL_CREDITSfor production cost-control budgets to alert on true list-price consumption. - Treat budgets as alerts, not caps. A
google_billing_budgetnever stops spend on its own. If you need a hard stop, pair it with a Pub/Sub-triggered function that callsprojects.updateBillingInfoto detach billing — and test that path on a sandbox first. - Name budgets for ownership and scope. Encode project/team and period in
display_name(e.g.payments-prod — monthly) so a 200-budget billing console stays navigable and alert emails are self-identifying. - Scope tightly with filters, version with tags. Use
included_projects,included_services, orincluded_labelsrather than one giant account-wide budget, and always pin the module?ref=v1.0.0so a threshold change is a reviewed, deliberate version bump.