Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for google_cloudbuild_trigger: GitHub/Cloud Source push and PR triggers, inline or file-based build steps, substitutions, and a dedicated service account. 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 "cloud_build" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-build?ref=v1.0.0"
project_id = "..." # GCP project that owns the trigger.
name = "..." # Trigger name; also derives the SA `account_id` (`cb-<na…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Google Cloud Build is GCP’s managed CI/CD execution service: it pulls source from a connected repository (GitHub, GitLab, Bitbucket, or Cloud Source Repositories), runs an ordered set of containerized build steps on Google-hosted or private worker pools, and pushes artifacts to Artifact Registry, GKE, Cloud Run, or wherever your pipeline targets. The unit that ties “when something changes” to “run this build” is the trigger — google_cloudbuild_trigger — and that is exactly the resource that tends to drift when teams click it together in the console.
This module wraps google_cloudbuild_trigger so that the event source (which repo, which branch/tag regex, push vs. pull request), the build definition (an inline build spec or a path to a cloudbuild.yaml), the substitutions, and the identity the build runs as are all declared once, version-pinned, and reproducible across dev/staging/prod projects. It optionally provisions a dedicated, least-privilege service account for the build so you are not silently running every pipeline as the legacy @cloudbuild.gserviceaccount.com agent with broad project rights. The result is that promoting a pipeline between environments is a terraform apply with two changed variables, not a 20-field form re-entry.
When to use it
- You want CI triggers (build on push to
main, build on PR, build on tag) defined as code and identical across every GCP project. - You are standardizing on a dedicated build service account per pipeline for least privilege and clean audit trails, instead of the default Cloud Build service agent.
- You need both styles of trigger source in one place: managed GitHub repos via Cloud Build GitHub App (
githubblocks) and/or Cloud Source Repositories (trigger_template). - You want to centralize substitutions (image tags, region, environment) and
included_files/ignored_filespath filters so builds only fire on relevant changes. - Skip it if you only ever run ad-hoc
gcloud builds submitjobs with no triggers — there is no trigger resource to manage, and this module would be empty overhead.
Module structure
terraform-module-gcp-cloud-build/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Build a default SA email only when we are creating one.
create_sa = var.create_service_account
service_account_email = local.create_sa ? google_service_account.build[0].email : var.service_account_email
# Cloud Build wants the SA as a fully-qualified resource path, not a bare email.
service_account_id = local.service_account_email != null ? "projects/${var.project_id}/serviceAccounts/${local.service_account_email}" : null
}
# Optional dedicated, least-privilege service account for this pipeline.
resource "google_service_account" "build" {
count = local.create_sa ? 1 : 0
project = var.project_id
account_id = "cb-${var.name}"
display_name = "Cloud Build trigger SA for ${var.name}"
description = "Runtime identity for the '${var.name}' Cloud Build trigger. Managed by Terraform."
}
# Project-level roles granted to the dedicated SA (only when we create it).
resource "google_project_iam_member" "build_roles" {
for_each = local.create_sa ? toset(var.service_account_roles) : toset([])
project = var.project_id
role = each.value
member = "serviceAccount:${google_service_account.build[0].email}"
}
# Cloud Build's own service agent must be able to mint tokens for a user-specified
# build SA. We grant actAs so the trigger can run as our dedicated identity.
resource "google_service_account_iam_member" "act_as" {
count = local.create_sa && var.grant_act_as ? 1 : 0
service_account_id = google_service_account.build[0].name
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${var.cloud_build_service_agent}"
}
resource "google_cloudbuild_trigger" "this" {
project = var.project_id
location = var.location
name = var.name
description = var.description
disabled = var.disabled
service_account = local.service_account_id
tags = var.tags
# Path filters: only fire when relevant files change (or never on others).
included_files = var.included_files
ignored_files = var.ignored_files
substitutions = var.substitutions
# --- Source: GitHub (Cloud Build GitHub App) ---
dynamic "github" {
for_each = var.github == null ? [] : [var.github]
content {
owner = github.value.owner
name = github.value.name
dynamic "push" {
for_each = github.value.push == null ? [] : [github.value.push]
content {
branch = push.value.branch
tag = push.value.tag
invert_regex = push.value.invert_regex
}
}
dynamic "pull_request" {
for_each = github.value.pull_request == null ? [] : [github.value.pull_request]
content {
branch = pull_request.value.branch
comment_control = pull_request.value.comment_control
invert_regex = pull_request.value.invert_regex
}
}
}
}
# --- Source: Cloud Source Repositories ---
dynamic "trigger_template" {
for_each = var.trigger_template == null ? [] : [var.trigger_template]
content {
project_id = coalesce(trigger_template.value.project_id, var.project_id)
repo_name = trigger_template.value.repo_name
branch_name = trigger_template.value.branch_name
tag_name = trigger_template.value.tag_name
invert_regex = trigger_template.value.invert_regex
}
}
# --- Build definition: point at a YAML in the repo ... ---
filename = var.build_config_filename
# --- ... OR define the build inline (mutually exclusive with filename) ---
dynamic "build" {
for_each = var.inline_build == null ? [] : [var.inline_build]
content {
timeout = build.value.timeout
images = build.value.images
dynamic "options" {
for_each = build.value.machine_type == null && build.value.logging == null ? [] : [1]
content {
machine_type = build.value.machine_type
logging = build.value.logging
}
}
dynamic "step" {
for_each = build.value.steps
content {
id = step.value.id
name = step.value.name
entrypoint = step.value.entrypoint
args = step.value.args
env = step.value.env
dir = step.value.dir
}
}
}
}
}
# variables.tf
variable "project_id" {
description = "GCP project ID that owns the Cloud Build trigger."
type = string
}
variable "name" {
description = "Trigger name. Used verbatim and to derive the SA account_id (cb-<name>)."
type = string
validation {
# account_id becomes "cb-<name>" and must be 6-30 chars, so name <= 27.
condition = can(regex("^[a-z][a-z0-9-]{1,26}$", var.name))
error_message = "name must be 2-27 chars, lowercase letters/digits/hyphens, starting with a letter."
}
}
variable "location" {
description = "Trigger location. Use 'global' or a region (e.g. us-central1) — regional triggers are required for regional/private worker pools."
type = string
default = "global"
}
variable "description" {
description = "Human-readable description shown in the console."
type = string
default = "Managed by Terraform"
}
variable "disabled" {
description = "If true, the trigger exists but will not fire."
type = bool
default = false
}
variable "tags" {
description = "Trigger tags (free-form labels for filtering in the Cloud Build UI/API)."
type = list(string)
default = []
}
variable "substitutions" {
description = "User-defined substitutions injected into the build (keys must start with an underscore, e.g. _REGION)."
type = map(string)
default = {}
validation {
condition = alltrue([for k in keys(var.substitutions) : can(regex("^_[A-Z0-9_]+$", k))])
error_message = "Substitution keys must start with '_' and contain only A-Z, 0-9 and underscores (e.g. _IMAGE_TAG)."
}
}
variable "included_files" {
description = "Glob patterns; the trigger only fires when at least one changed file matches."
type = list(string)
default = []
}
variable "ignored_files" {
description = "Glob patterns; changes only to these files will not fire the trigger."
type = list(string)
default = []
}
# --- Source configuration (provide exactly one of github / trigger_template) ---
variable "github" {
description = "GitHub (Cloud Build GitHub App) source. Provide a push OR pull_request block."
type = object({
owner = string
name = string
push = optional(object({
branch = optional(string)
tag = optional(string)
invert_regex = optional(bool, false)
}))
pull_request = optional(object({
branch = string
comment_control = optional(string, "COMMENTS_ENABLED")
invert_regex = optional(bool, false)
}))
})
default = null
}
variable "trigger_template" {
description = "Cloud Source Repositories source. Provide branch_name OR tag_name."
type = object({
repo_name = string
project_id = optional(string)
branch_name = optional(string)
tag_name = optional(string)
invert_regex = optional(bool, false)
})
default = null
}
# --- Build definition (provide exactly one of build_config_filename / inline_build) ---
variable "build_config_filename" {
description = "Path within the repo to a cloudbuild.yaml/json build config. Mutually exclusive with inline_build."
type = string
default = null
}
variable "inline_build" {
description = "Inline build definition (steps/images/options). Mutually exclusive with build_config_filename."
type = object({
timeout = optional(string, "600s")
images = optional(list(string), [])
machine_type = optional(string)
logging = optional(string)
steps = list(object({
id = optional(string)
name = string
entrypoint = optional(string)
args = optional(list(string))
env = optional(list(string))
dir = optional(string)
}))
})
default = null
}
# --- Service account / identity ---
variable "create_service_account" {
description = "Create a dedicated least-privilege SA (cb-<name>) for this trigger."
type = bool
default = true
}
variable "service_account_email" {
description = "Existing SA email to run builds as. Used only when create_service_account = false."
type = string
default = null
}
variable "service_account_roles" {
description = "Project roles granted to the created SA. Keep this tight (logging + only what the build needs)."
type = list(string)
default = [
"roles/logging.logWriter",
"roles/artifactregistry.writer",
]
}
variable "grant_act_as" {
description = "Grant the Cloud Build service agent serviceAccountUser on the created SA (needed to run as it)."
type = bool
default = true
}
variable "cloud_build_service_agent" {
description = "Cloud Build service agent email used for the actAs grant. Typically <PROJECT_NUMBER>@cloudbuild.gserviceaccount.com."
type = string
default = null
validation {
condition = var.cloud_build_service_agent == null || can(regex("@cloudbuild\\.gserviceaccount\\.com$", coalesce(var.cloud_build_service_agent, "x")))
error_message = "cloud_build_service_agent must end with @cloudbuild.gserviceaccount.com."
}
}
# outputs.tf
output "trigger_id" {
description = "Fully-qualified Cloud Build trigger ID (projects/.../triggers/...)."
value = google_cloudbuild_trigger.this.id
}
output "trigger_uid" {
description = "Server-assigned unique identifier (trigger_id attribute) for the trigger."
value = google_cloudbuild_trigger.this.trigger_id
}
output "name" {
description = "Trigger name."
value = google_cloudbuild_trigger.this.name
}
output "location" {
description = "Location the trigger was created in (global or a region)."
value = google_cloudbuild_trigger.this.location
}
output "service_account_email" {
description = "Email of the identity builds run as (created or supplied)."
value = local.service_account_email
}
output "service_account_name" {
description = "Fully-qualified resource name of the created SA, or null if an existing SA was supplied."
value = local.create_sa ? google_service_account.build[0].name : null
}
How to use it
Build on every push to main in a GitHub repo, running as a dedicated SA that can write to Artifact Registry and deploy to Cloud Run, using the repo’s cloudbuild.yaml:
module "cloud_build" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-build?ref=v1.0.0"
project_id = "kv-platform-prod"
name = "api-deploy-main"
location = "us-central1"
github = {
owner = "teknohut"
name = "kloudvin-api"
push = {
branch = "^main$"
}
}
build_config_filename = "deploy/cloudbuild.yaml"
substitutions = {
_REGION = "us-central1"
_SERVICE = "kloudvin-api"
_IMAGE_TAG = "latest"
}
# Only rebuild when app or build config changes.
included_files = ["src/**", "deploy/cloudbuild.yaml", "Dockerfile"]
create_service_account = true
cloud_build_service_agent = "418273645091@cloudbuild.gserviceaccount.com"
service_account_roles = [
"roles/logging.logWriter",
"roles/artifactregistry.writer",
"roles/run.developer",
"roles/iam.serviceAccountUser",
]
tags = ["prod", "api"]
}
# Downstream: let the runtime Cloud Run service impersonate the same build SA,
# and surface the trigger UID to a monitoring/alerting module.
resource "google_cloud_run_v2_service_iam_member" "deployer" {
project = "kv-platform-prod"
location = "us-central1"
name = "kloudvin-api"
role = "roles/run.developer"
member = "serviceAccount:${module.cloud_build.service_account_email}"
}
output "ci_trigger_uid" {
value = module.cloud_build.trigger_uid
}
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/cloud_build/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-build?ref=v1.0.0"
}
inputs = {
project_id = "..."
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloud_build && 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 |
|---|---|---|---|---|
project_id |
string |
— | Yes | GCP project that owns the trigger. |
name |
string |
— | Yes | Trigger name; also derives the SA account_id (cb-<name>). |
location |
string |
"global" |
No | global or a region; regional is required for regional/private worker pools. |
description |
string |
"Managed by Terraform" |
No | Console description. |
disabled |
bool |
false |
No | Create the trigger but prevent it from firing. |
tags |
list(string) |
[] |
No | Free-form trigger tags for filtering. |
substitutions |
map(string) |
{} |
No | User substitutions; keys must start with _. |
included_files |
list(string) |
[] |
No | Globs that must match a changed file for the trigger to fire. |
ignored_files |
list(string) |
[] |
No | Globs; changes only to these never fire the trigger. |
github |
object(...) |
null |
Cond. | GitHub App source with a push or pull_request block. |
trigger_template |
object(...) |
null |
Cond. | Cloud Source Repositories source (branch_name or tag_name). |
build_config_filename |
string |
null |
Cond. | Path to a cloudbuild.yaml; exclusive with inline_build. |
inline_build |
object(...) |
null |
Cond. | Inline steps/images/options; exclusive with build_config_filename. |
create_service_account |
bool |
true |
No | Create a dedicated least-privilege SA for the build. |
service_account_email |
string |
null |
Cond. | Existing SA email (used only when create_service_account = false). |
service_account_roles |
list(string) |
["roles/logging.logWriter","roles/artifactregistry.writer"] |
No | Project roles for the created SA. |
grant_act_as |
bool |
true |
No | Grant the Cloud Build agent serviceAccountUser on the created SA. |
cloud_build_service_agent |
string |
null |
Cond. | <PROJECT_NUMBER>@cloudbuild.gserviceaccount.com; needed for the actAs grant. |
Outputs
| Name | Description |
|---|---|
trigger_id |
Fully-qualified trigger ID (projects/.../triggers/...). |
trigger_uid |
Server-assigned unique trigger identifier. |
name |
Trigger name. |
location |
Location the trigger was created in. |
service_account_email |
Email of the identity builds run as. |
service_account_name |
Resource name of the created SA, or null if an existing SA was supplied. |
Enterprise scenario
A fintech platform team runs ~40 microservices across dev, staging, and prod GCP projects and was previously creating Cloud Build triggers by hand — every pipeline ran as the shared default Cloud Build service agent, which had roles/editor-level reach. They adopted this module so each service gets its own cb-<service> SA scoped to exactly logging.logWriter, artifactregistry.writer, and run.developer, the GitHub push/PR triggers are byte-for-byte identical across environments via a for_each over a services map, and included_files filters cut wasted prod builds (and the associated build-minute spend) by roughly a third. When auditors asked “what can this pipeline touch,” the answer became a single Terraform-managed IAM binding instead of a project-wide editor role.
Best practices
- Run every pipeline as a dedicated SA, never the default service agent. Keep
create_service_account = trueand grant only the roles a given build needs (logging.logWriterplus, e.g.,artifactregistry.writer) — the default@cloudbuild.gserviceaccount.comagent is far too broad for production pipelines. - Pin trigger source regexes and scope them. Use anchored patterns like
^main$and^release/.*$rather than bare branch names so an unexpectedmain-hotfixbranch does not silently match, and combine withincluded_filesso builds only fire on relevant changes. - Cut cost with path filters and timeouts.
included_files/ignored_filesstop docs-only or unrelated commits from burning build minutes, and an explicittimeoutoninline_buildcaps runaway steps; both directly reduce Cloud Build spend. - Go regional when you use private/worker pools. Set
locationto the worker pool’s region (notglobal) so the trigger, pool, and your VPC-bound resources stay co-located — global triggers cannot target regional private pools. - Keep secrets out of substitutions. Substitutions land in build logs; reference Secret Manager from
cloudbuild.yaml(availableSecrets) instead of passing tokens throughsubstitutions. - Name predictably and tag. Use a
<service>-<event>convention (api-deploy-main,api-pr-check) and populatetagsso triggers are filterable in the console and traceable back to their Terraform module instance.