Terraform in Cloud Build
Cloud Build is GCP's native CI runner, and running Terraform in it means a cloudbuild.yaml with terraform init, plan, and apply steps, a Cloud Build service account that holds the GCP permissions, and a trigger that fires on push or pull request to hatch-io/infrastructure. The whole thing runs inside GCP with no key file, because each Cloud Build step authenticates as the build's attached service account — the Application Default Credentials posture from Chapter 2, now in CI.
The shift this makes is that the laptop stops being where apply happens. The pipeline becomes the only thing that mutates infrastructure, the build's service account defines exactly what it may change, and a push to the repo — not a human typing a command — is what kicks off a run. Everything in this topic serves making that pipeline safe and reproducible.
The cloudbuild.yaml Shape
A cloudbuild.yaml is a steps list, where each step is a container that runs to completion before the next begins. For Terraform, the hashicorp/terraform builder image runs init, then plan, then conditionally apply, with the working directory and the args set per step and the state backend pointing at the hatch-tfstate bucket. The shape is linear and explicit: each step is one Terraform command.
The build below initializes against the GCS backend, writes a plan to a file, then applies that exact file. Read it as three containers in sequence sharing the checked-out workspace.
steps: - name: 'hashicorp/terraform:1.9.8' # pinned, never :latest args: ['init', '-input=false'] - name: 'hashicorp/terraform:1.9.8' args: ['plan', '-input=false', '-out=tfplan'] - name: 'hashicorp/terraform:1.9.8' args: ['apply', '-input=false', 'tfplan'] # applies the reviewed plan serviceAccount: 'terraform-ci@hatch-admin.iam.gserviceaccount.com'
Because the steps share the workspace, the tfplan file written by the plan step is the same artifact the apply step consumes. That handoff is what makes the apply deterministic, and it is the subject of the last section.
The Cloud Build Service Account
Every build runs as a service account — either a default one Cloud Build picks for you or, better, a dedicated terraform-ci SA. Its IAM roles are exactly what Terraform is allowed to change, so the build can apply only what that SA has been granted. The service account is the pipeline's blast radius made concrete: grant it permission over the network and apps it manages, and nothing more.
The default SA is the trap here. The default Cloud Build picks — the Compute Engine default service account on projects whose first build ran after the May 2024 change, or the legacy @cloudbuild.gserviceaccount.com account on older ones — is often granted the broad roles/editor on the project, which means a build running as it can change almost anything — far more than the infrastructure repo defines. A compromised build or a careless config then has project-wide reach. A dedicated least-privilege terraform-ci SA bounds the damage to what the pipeline actually manages.
Authenticating Without Keys
The Terraform step inherits the build's attached service account through Application Default Credentials, so terraform finds credentials with no GOOGLE_APPLICATION_CREDENTIALS set and no JSON key mounted anywhere. The provider asks the metadata server for a token, gets one minted for the build's SA, and authenticates. This is the in-GCP counterpart to Workload Identity Federation: when the runner already lives inside GCP, there is no token to exchange at all — the credentials are simply there.
For this to work, two grants from earlier chapters have to be in place: the Cloud Build API enabled, and the build's SA holding roles/storage.objectAdmin on hatch-tfstate so init can read and write state. Miss either and the build fails on the first init.
Triggers on Push and Pull Request
A google_cloudbuild_trigger decides which builds run and what they do. A push to the main branch runs the full init/plan/apply sequence; a pull request runs plan only. Branch and event filters draw that line, so a PR previews the change as a plan and a merge to main is what actually applies it. Running apply on a pull-request trigger is exactly backwards — it mutates prod before anyone has reviewed the diff.
Splitting the trigger this way is the core safety property of the pipeline. The diff a reviewer reads on the pull request is a plan; the apply only happens once that change has merged through review. Pull requests preview, merges apply.
Pinning and Passing the Plan Forward
Pin the hashicorp/terraform image to an exact version tag — 1.9.8, not latest — so the CI Terraform version matches what the team runs locally and what required_version declares. An unpinned latest image silently changes Terraform versions between builds, so plans diverge and the lock file fights the build. Pass -input=false and -no-color as well, so the non-interactive build never blocks on a prompt and its logs stay readable.
Write terraform plan -out=tfplan in one step and terraform apply tfplan in a later step, so apply executes exactly the reviewed plan rather than a fresh re-plan. If you re-plan at apply time instead, state may have moved between the steps and the applied changes can differ from what was reviewed — the artifact handoff is what closes that gap.
Default Cloud Build SA — the Compute Engine default service account on newer projects, or the legacy @cloudbuild.gserviceaccount.com account on older ones, often carrying roles/editor so a build can change almost anything in the project. Convenient to start, dangerous to keep: the pipeline's blast radius is the whole project, not the infrastructure it manages.
Dedicated terraform-ci SA — granted exactly the roles the managed infrastructure needs and the objectAdmin grant on the state bucket. The build can apply only what it owns, so a compromised build or bad config is bounded. Use this for any pipeline that touches prod.
- Running the build as the default Cloud Build SA with its broad
roles/editorgrant — the pipeline can change far more than the infrastructure repo defines, so a compromised build or bad config has project-wide blast radius. - Using the
hashicorp/terraform:latestimage and getting a different Terraform version than the team runs locally — plans diverge and the lock file fights the build. - Running
applyon every trigger including pull requests — a PR build mutating prod before review is exactly backwards; pull requests mustplanonly. - Re-planning at apply time instead of applying a saved
-outplan — the applied changes may differ from what was reviewed if state moved between the steps. - Forgetting that Cloud Build needs the Cloud Build API enabled and the state bucket's
objectAdmingrant on its SA — the build fails on the firstinitagainsthatch-tfstate.
- Run Terraform builds as a dedicated least-privilege
terraform-ciservice account, not the default Cloud Build SA withroles/editor. - Pin the
hashicorp/terraformbuilder image to an exact version that matchesrequired_version, and pass-input=falseso non-interactive builds never block on a prompt. - Split the trigger so pull requests run
planand only merges to the main branch runapply. - Save the plan with
-out=tfplanand apply that artifact, so CI applies exactly what was reviewed. - Grant the build's SA
objectAdminonhatch-tfstateand enable the Cloud Build API before the first run, soinitcan reach state.
Knowledge Check
How does a Cloud Build step authenticate to GCP without a key file?
- It inherits the build's attached service account through Application Default Credentials, so Terraform finds a token with no JSON key mounted
- It reads a long-lived service-account JSON key stored as a build secret and exports its file path through the GOOGLE_APPLICATION_CREDENTIALS variable
- It exchanges a short-lived GitHub OIDC token through the configured workload identity provider for a GCP token
- It runs as the human developer who pushed the triggering commit, reusing their own local gcloud credentials
Why does the build service account define the pipeline's blast radius?
- The build can change only what that SA is granted, so a broad-roled SA lets a bad config or compromised build reach far beyond the managed infrastructure
- The build SA controls how many parallel Cloud Build jobs are allowed to run at the same time, which is what actually bounds the potential damage purely by concurrency
- The SA name is recorded in the audit log on every change, so blast radius is really just an audit-trail concept
- Terraform automatically confines itself to the SA's home project on every apply, regardless of what roles are granted
Why must pull-request triggers run plan only and not apply?
- A PR is the review stage; applying on a PR mutates prod before anyone has reviewed the diff
- Cloud Build is technically unable to run a terraform apply on a pull-request trigger; it is a hard platform limitation
- Pull requests always run as a separate restricted SA that lacks access to the Cloud Build API entirely
- Running apply on a PR would permanently corrupt the GCS state lock object for that stack
Why save the plan with -out=tfplan and apply that file rather than re-planning at apply time?
- Applying the saved plan executes exactly what was reviewed; a fresh re-plan may differ if state moved between steps
- Applying a saved plan runs noticeably faster because it skips re-reading the provider schema at apply time
- Re-planning at apply time is simply not supported by the GCS backend, so you must save the plan first
- The saved plan file transparently encrypts the underlying state object so that the later apply step cannot accidentally leak it
You got correct