IaC GCP

Terraform Module: GCP Cloud Build — repeatable, least-privilege CI triggers as code

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 triggergoogle_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

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

TerraformGCPCloud BuildModuleIaC
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