IaC GCP

Terraform Module: GCP Billing Budget — Catch overspend before the invoice does

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

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 configlive/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 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-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

TerraformGCPBilling 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