Chapter 26

Terraform on GCP

Scope. This chapter owns Terraform as the book's provisioning tool: the google provider and how it authenticates, project and module structure, state and its protection, the plan/apply workflow and its approval, module design and versioning, and the anti-patterns that recur. It does not re-teach the services the modules build — Chapters 2, 5, 8, 9, 10, 12 and 14 own those. Chapter 27 owns OpenTofu and states only its differences; Chapter 28 owns Ansible and the seam between the two tools. Prerequisites. Chapter 2 (§2.21 security projects, §2.30 organization policy), Chapter 4 (§4.3 Workload Identity Federation, §4.7 GitHub Actions federation), Chapter 13 (§13.13 write-only arguments), Chapter 22 (§22.12 infrastructure code scanning), Chapter 23 (Cloud Build). Verified against. Google Cloud console and API surface as of 2026-09, Cloud SDK 583.0.0, Terraform 1.15.8, hashicorp/google provider 8.1.0; see sources at end.

Terraform is where a cloud estate's security posture is actually written down. Every control the preceding chapters describe — a constraint, a firewall policy, a CMEK binding, a service account with three roles instead of thirty — exists in production because a configuration file said so and a pipeline applied it. That makes the Terraform repository a tier-zero system: whoever can merge to it can change any control in the estate, and whoever can read its state can read a good deal of what those controls protect.

This chapter treats Terraform as a security control surface rather than a provisioning convenience. Three questions run through it. How does Terraform prove who it is, without a downloaded key? Where does state live, who can read it, and what does it contain that they should not see? And what stands between a proposed change and a control being silently removed? The first is §26.3 and §26.4, the second is §26.13 through §26.18, and the third is §26.23 through §26.25.

The material assumes you can already write HCL. What it does not assume is that the defaults are safe: Terraform's defaults are tuned for a single engineer iterating quickly, and almost every one of them — local state, a broad set of credentials, an apply with no saved plan — is wrong for an estate with more than one person in it.

26.1 Terraform Architecture §

Terraform is a graph engine with a plugin protocol: the core builds a dependency graph from configuration and state, and providers translate graph nodes into API calls.

Three inputs decide every plan, and confusing them is the source of most surprising diffs:

InputWhat it isWhere it lives
ConfigurationThe desired state you wrote*.tf files
StateWhat Terraform believes it createdThe backend (§26.14)
RefreshWhat the API reports right nowThe provider, at plan time

A plan is the difference between configuration and refreshed state, not between configuration and reality. A resource Terraform does not know about is invisible to it — this is why drift detection (§26.22) is a separate discipline from planning, and why an estate with resources created by hand has no meaningful plan output.

The provider is a separate binary with its own version. terraform init resolves the version constraint, downloads the plugin, and records the exact version and its checksums in .terraform.lock.hcl. That lock file is a supply chain control: it pins the binary that will hold your credentials and call your APIs. Commit it (§26.35).

The core is provider-agnostic and knows nothing about Google Cloud. Every GCP-specific behavior — that a project must exist before a resource in it, that a subnetwork belongs to a network — is either encoded by the provider or expressed by you as a dependency (§26.12). Terraform will happily plan an impossible ordering if nothing tells it otherwise.

Pitfall. The graph is built from the configuration, so a resource that reads another resource's output through a value Terraform cannot trace — a hardcoded string, a local-exec side effect — is unordered. It works until a parallel apply happens to schedule it first.

26.2 Google Provider §

hashicorp/google is the generally available provider covering the GA surface of Google Cloud APIs; hashicorp/google-beta is the same codebase with pre-GA resources and fields added.

Pin both at the same major version and declare them in the root module only, never in a shared module (§26.7):

terraform {
  required_version = ">= 1.11"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = "us-central1"
}

~> 8.0 accepts any 8.x release. That is the book's pin throughout: it takes bug fixes and new resources automatically while refusing the next major, where breaking changes land. The lock file makes the actually-resolved version reproducible, so the loose constraint costs nothing.

Use google-beta only for a specific resource, not globally. Adding it as an alias and setting provider = google-beta on the one resource that needs a pre-GA field keeps the rest of the configuration on the GA surface. A whole configuration on the beta provider silently makes every resource's behavior a Preview dependency.

default_labels on the provider block applies labels to every resource that supports them, which is the least-effort way to satisfy the mandatory label set from §2.26 without repeating it on 200 resources.

Pitfall. The provider and the API are versioned independently, so a field can be generally available in the API and absent from the provider for weeks. When a --help-confirmed gcloud flag has no HCL equivalent, that is usually the reason; the fix is a provider upgrade, not a local-exec workaround (§26.35).

26.3 Provider Authentication §

The provider obtains credentials from exactly one source per execution, resolved in a fixed precedence, and the whole security question is which source you let it find.

The precedence, highest first:

SourceProvider argumentEnvironment variable
External credentialsexternal_credentials—
Explicit key materialcredentialsGOOGLE_CREDENTIALS
Explicit tokenaccess_tokenGOOGLE_OAUTH_ACCESS_TOKEN
Application Default Credentials—GOOGLE_APPLICATION_CREDENTIALS

Google's own position is that none of these should be a downloaded key. Its Terraform guidance says to run pipelines on Google Cloud "because Cloud Build, Google Kubernetes Engine, or Compute Engine inject credentials without downloading service account keys", to "prefer workload identity federation" elsewhere (§26.4), and for local work to run gcloud auth application-default login rather than downloading anything.

Impersonation is the pattern for a human operator. Set impersonate_service_account on the provider and the operator's own identity is used only to mint a token for the target account; permissions and quota are then evaluated against the impersonated account, not the human. The operator needs roles/iam.serviceAccountTokenCreator on that account and nothing else:

provider "google" {
  project                     = var.project_id
  region                      = "us-central1"
  impersonate_service_account = "sa-tf-plan@rc-saas-shared-cicd-01.iam.gserviceaccount.com"
}

This is a meaningful audit improvement, not just a convenience: the Admin Activity log records the impersonation chain, so an action is attributable to both the human and the account (§17.19).

Pitfall. access_token and external_credentials produce a token the provider cannot renew, and it expires — one hour by default. An apply longer than the token's life fails partway through, having already created resources. For long applies, use a credential source the provider can refresh.

26.4 Workload Identity Federation §

§4.3 owns the federation model and §4.7 owns the GitHub Actions configuration. This section owns only what a Terraform run needs on top of them: the pool, the pinned attribute condition, and the two-step permission chain a plan actually exercises.

Terraform in CI needs two distinct grants, and they are constantly conflated:

GrantOn whatWhy the run needs it
roles/iam.workloadIdentityUserThe deploy service accountLets the federated principal become that account
roles/iam.serviceAccountUserAny account Terraform attachesSupplies iam.serviceAccounts.actAs

The first is the exchange; the second is the attachment. Federating into sa-deploy-prod requires only the first. But the moment that configuration creates a Cloud Run service, a GKE node pool, or a VM with a service account on it, Terraform must additionally hold iam.serviceAccounts.actAs on that account, or the apply fails with a permission error naming a resource nobody was thinking about.

Bind the principal set by attribute, and pin the attribute to a numeric ID:

resource "google_service_account_iam_member" "ci_federation" {
  service_account_id = google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member = join("", [
    "principalSet://iam.googleapis.com/projects/${var.project_number}",
    "/locations/global/workloadIdentityPools/${var.pool_id}",
    "/attribute.repository_id/${var.repository_id}",
  ])
}

A principalSet on a repository name is a rename away from being wrong, and a repository name can be released and re-registered by someone else. The numeric repository ID cannot. §4.7 carries the attribute mapping that makes attribute.repository_id available.

The provider can also consume the token directly through its external_credentials block — audience, identity_token, and service_account_email, all required — which avoids writing a credential configuration file to the runner's disk at all.

Pitfall. The pool and provider that federation depends on cannot themselves be created by the federated pipeline; there is no identity yet. They belong to the bootstrap layer (§2.21), applied once by a human with impersonation (§26.3), and that layer's own state is the one place the chicken-and-egg problem is resolved by hand.

26.5 Terraform Project Structure §

Google's published structure is two top-level directories — modules/ holding the configuration, environments/ holding one root module per environment — and it is worth following because the alternative fails in a specific way.

terraform/
  modules/
    network/        main.tf variables.tf outputs.tf versions.tf README.md
    gke-cluster/
    security-baseline/
  environments/
    dev/            backend.tf main.tf terraform.tfvars versions.tf
    stg/
    prod/

Each environment directory is a separate root module with its own backend, which is what makes §26.18's blast-radius argument true. It is also why Google says to "use only the default workspace": CLI workspaces put several environments behind one backend, and that backend becomes a single point of failure for all of them.

Keep a root configuration small. Google's rule is not to exceed 100 resources in one state and ideally to stay in the low dozens. The reason is operational rather than aesthetic — plan time, lock contention, and the size of the thing a single mistaken apply can destroy all scale with it.

Files inside a module are grouped by subject, not one per resource: main.tf, variables.tf, outputs.tf, versions.tf, and then descriptive groupings such as network.tf or iam.tf when a module grows. Configuration objects are named with underscores; a resource that is the only one of its type is named main; the resource name never repeats the type.

Pitfall. Splitting by environment only works if the environments are genuinely separate root modules. A single root module with count = var.is_prod ? 1 : 0 scattered through it has one state, one lock, and one blast radius, and the directory names are decoration.

26.6 Root Modules §

A root module is the directory Terraform is actually run in: it owns the backend, the provider configuration, and the variable values, and it is the only place any of those three may appear.

What belongs in a root module and nowhere else:

  • backend.tf — the state location (§26.14). A module cannot have one.
  • versions.tf — the required_providers block and the pinned versions.
  • The provider blocks, including impersonation and default_labels.
  • terraform.tfvars — the environment's values.
  • .terraform.lock.hcl, committed to version control.

Supply variables through terraform.tfvars, not command-line options. Google's guidance is explicit that -var and alternative var-files are "ephemeral and easy to forget"; a value that exists only in someone's shell history is a value nobody can review. The reviewable object is the file.

Root module outputs are the only supported cross-configuration interface. Another environment or application may consume a root module's outputs; reaching into an internal module's state is not a contract and breaks on refactor.

A root module should instantiate modules, not declare resources. When a root module accumulates its own resources, they are the ones that never get tested, never get versioned, and never get reused — and they are usually the security-relevant ones, because they were added in a hurry.

Pitfall. A root module inherits nothing. Each environment's versions.tf is a separate file, and they drift: production ends up on a provider version staging has never run. Keep them identical and change them together, or the pre-production test proves nothing about production.

26.7 Child Modules §

A child module is a reusable unit with an input contract, an output contract, and no opinion about where it runs.

The three rules that make a module composable, all of which Google states as requirements:

  1. A shared module must not configure providers or backends. It declares minimum provider versions in required_providers and inherits the configured provider from its caller. A module that configures a provider cannot be used twice with different credentials.
  2. Every resource gets at least one output referencing it. Without one, the caller cannot express a dependency on the thing the module made, and §26.12's implicit ordering silently does not apply.
  3. Inline modules live in modules/<name> and are private. They are implementation detail of their parent, not a public interface, and outside callers must not reach into them.

Internal module outputs are not exposed automatically. A parent that wraps a child must re-export the outputs it wants callers to see, which is a deliberate narrowing rather than an oversight — the module's surface is the outputs you chose to re-export.

Refactoring resources into a submodule changes their addresses, and Terraform reads an address change as destroy-and-recreate. moved blocks are the fix, and for a stateful resource they are the difference between a refactor and an outage.

Pitfall. A module that declares enable_apis and enables services must set the underlying resource's disable_on_destroy to false. Otherwise destroying one instance of the module disables an API that a second instance in the same project is still using, and unrelated infrastructure fails.

26.8 Variables §

A variable is the module's input contract, and its type and validation are the only enforcement the contract gets.

Give environment-specific variables no default. Google's rule is that values such as a project ID must be supplied by the caller, because a default is a value that will eventually be used by accident in the wrong environment. Conversely, a variable that has a safe default and rarely changes should have one — adding a defaulted variable is backwards-compatible, removing any variable is not.

Type and validate, especially anything security-relevant:

variable "authorized_networks" {
  type        = list(string)
  description = "CIDR ranges permitted to reach the control plane."

  validation {
    condition     = !contains(var.authorized_networks, "0.0.0.0/0")
    error_message = "The control plane must not be open to the internet."
  }
}

A validation block is a control that runs at plan time, before anything exists and before a reviewer has to notice. It is the cheapest place in the entire pipeline to reject a bad value, and it fails with a message you wrote rather than a permission error from an API.

Name numeric variables with their units — ram_size_gb, retention_days — and give booleans positive names such as enable_external_access, so a caller reading enable_external_access = false cannot misread a double negative.

Be sparing. Only parameterize what genuinely varies per instance or environment; every additional variable is a way for a caller to configure the module into an unsafe state (§26.31).

Pitfall. sensitive = true keeps a value out of CLI output. It does not keep it out of state, and it does not keep it out of a plan file. Marking a password sensitive and then storing it in state is the exact mistake §26.17 and §13.13 exist to prevent.

26.9 Outputs §

An output is how a module tells its caller what it built, and how Terraform learns that one thing depends on another.

Reference a resource attribute, never an input variable. Passing an input straight through to an output looks equivalent and is not: it breaks the dependency graph, because the output no longer depends on the resource having been created. The caller then gets a value before the thing it names exists.

output "network_self_link" {
  description = "Self link of the VPC, for callers attaching subnetworks."
  value       = google_compute_network.main.self_link
}

Mark sensitive outputs and understand the limit. sensitive = true redacts the value from plan and apply output. The value is still in state in the clear, and a caller can still consume it — the flag protects the terminal and the CI log, which is worth having, but it is not a confidentiality control (§26.17).

Outputs are a versioned interface. Removing one, or changing its type, breaks every caller, and callers are pinned to a version precisely so that does not happen unannounced (§26.26).

Pitfall. An output that exposes a generated credential — a service account key, a database password — makes every consumer's state a copy of that secret, and the blast radius of the state file (§26.17) grows to include every configuration that ever read the output. Return the Secret Manager resource name and let the consumer read the secret at runtime (§13.7).

26.10 Locals §

A local value names an expression so it is computed once and read many times; it is the right tool for a value derived inside the module and the wrong tool for a value the caller should control.

The distinction is who owns the value. A variable is an input the caller decides. A local is a conclusion the module reaches. Exposing a local as a variable invites a caller to set it inconsistently with the logic that derived it.

locals {
  name_prefix = "${var.prefix}-${var.environment}"

  common_labels = merge(var.labels, {
    env        = var.environment
    managed-by = "terraform"
  })
}

Locals are how you keep complex logic readable. Google's style rule is never to put more than one ternary on a line and to build the logic up through locals instead — which matters because a nested conditional in a security-relevant argument is where a reviewer stops reading.

Locals also solve the long-string problem in HCL. A backslash continuation is a bash habit and is not valid inside an HCL string; when a value is too long for one line, build it in a local with join():

locals {
  perimeter_services = join(",", [
    "storage.googleapis.com",
    "bigquery.googleapis.com",
    "cloudkms.googleapis.com",
  ])
}

Pitfall. Locals are evaluated eagerly and cannot be overridden per call site. A local that encodes an environment-specific decision makes the module usable in one environment; that decision was a variable and should have been declared as one.

26.11 Data Sources §

A data source reads existing infrastructure into the graph. It is how a configuration references something another configuration owns, without claiming it.

The security value is that a data source cannot change anything. Reading the shared VPC with google_compute_network rather than declaring it means the network's owning configuration remains its only writer, and a mistake here cannot destroy it.

data "google_compute_network" "shared" {
  name    = "rc-saas-shared-vpc-01"
  project = var.host_project_id
}

Put data sources next to the resources that use them, moving them to a data.tf only when there are enough to obscure the resources. The reader needs to see the lookup and its consumer together.

Some data sources return secrets into state. google_client_config exposes an access token; google_service_account_key exposes key material. Provider 8 supplies ephemeral equivalents for both, and an ephemeral resource is never written to state or to a plan file (§26.17). Prefer them wherever they exist.

Pitfall. A data source is resolved at plan time against whatever exists then. If the resource it reads is created by the same apply, the plan cannot resolve it and Terraform reports values it cannot determine — the fix is a real dependency on the resource, not a data source that races it.

26.12 Resource Dependencies §

Terraform orders operations from the graph, and the graph is built almost entirely from references you did not write deliberately.

Implicit dependencies are the good kind. Writing network = google_compute_network.main.id tells Terraform both what value to use and what must exist first. There is one fact, in one place, and it cannot go stale.

depends_on is for the dependency you cannot express as a reference — most often an IAM binding that must exist before a resource can use the identity it grants:

resource "google_project_service" "compute" {
  project = var.project_id
  service = "compute.googleapis.com"

  disable_on_destroy = false
}

resource "google_compute_network" "main" {
  name                    = "${local.name_prefix}-vpc"
  auto_create_subnetworks = false

  depends_on = [google_project_service.compute]
}

API enablement is the canonical case. Nothing in a network resource references the service that must be enabled to create it, so without depends_on the first apply of a new project fails on a race it will win on the retry — which is how "just run it again" becomes an accepted part of a team's process.

depends_on on a module applies to everything in it, which is a blunt instrument that serializes more than it needs to. Prefer passing a value the module actually uses.

Pitfall. Eventual consistency in IAM is not a dependency problem and depends_on does not fix it. A binding can be committed and not yet visible to the API that checks it seconds later. That is a retry, not an ordering error, and adding more depends_on in response makes applies slower without making them more reliable.

26.13 Terraform State §

State is Terraform's record of which real resources correspond to which configuration addresses, plus the last-known values of their attributes.

It is three things at once, and only the first is obvious:

RoleConsequence
An address-to-resource mapLosing it orphans every resource Terraform manages
A performance cacheAttribute values are read from it, not the API, unless refreshed
A data storeIt holds every attribute value, including sensitive ones

The third role is the security one. State is plaintext JSON. Any attribute the provider read or wrote is in it — generated passwords, certificate private keys, service account key material, the full contents of resources you thought of as configuration. §2.21 therefore treats state as a secret store and places the bootstrap state bucket in the security project.

State also holds one thing that is not an attribute at all: the plan's view of what exists. A resource removed from configuration is destroyed on the next apply precisely because state says Terraform created it. That is the whole safety model, and it is why terraform state surgery (§26.35) is dangerous in a way that editing configuration is not.

Never commit state to version control. Add *.tfstate and *.tfstate.* to .gitignore before the first apply, not after — a state file in Git history is a secret in Git history, and §33.15 owns why that is not fixed by deleting the file.

Pitfall. terraform show -json and a saved plan file both contain the same sensitive values as state, in the same plaintext. A plan artifact published to a CI job's output, or attached to a pull request for review, leaks exactly what a leaked state file leaks (§26.23).

26.14 Remote State in Cloud Storage §

The gcs backend stores state as an object in a bucket you create beforehand. §2.21 places the bootstrap state bucket in the security project; this section owns the bucket's own configuration.

The backend block, in the root module only:

terraform {
  backend "gcs" {
    bucket = "rc-saas-bootstrap-tfstate-01"
    prefix = "envs/prod"
  }
}

Named states live at <prefix>/<name>.tfstate, so one bucket can serve every environment while each root module keeps a distinct object — and distinct object-level access, which is the point.

The bucket needs two commands, because versioning is not a create-time flag:

gcloud storage buckets create gs://rc-saas-bootstrap-tfstate-01 \
  --project=rc-saas-shared-sec-01 \
  --location=us-central1 \
  --uniform-bucket-level-access \
  --public-access-prevention \
  --default-encryption-key=$KMS_KEY

gcloud storage buckets update gs://rc-saas-bootstrap-tfstate-01 \
  --versioning

Object versioning is the state-recovery mechanism, and HashiCorp's own documentation flags it as highly recommended: a corrupted or truncated state write is recoverable only if the previous generation still exists. Uniform bucket-level access removes per-object ACLs so the bucket's IAM is the whole access story, and public access prevention makes the failure in §33.1 impossible on this bucket specifically.

Grant roles/storage.objectAdmin on the bucket to the pipeline's service account and to nobody else. The backend documentation names Storage Object Admin as the requirement; read access to this bucket is read access to every credential the estate has ever generated.

Pitfall. Credentials passed through -backend-config are written into the .terraform directory and into plan files. HashiCorp warns about this explicitly. Supply backend credentials through the environment, or through impersonate_service_account in the backend block, which names an account rather than carrying a secret.

26.15 State Locking Considerations §

The gcs backend supports state locking natively, with no second service to run and nothing extra to provision.

The mechanism is a conditional object write. Terraform creates <prefix>/<name>.tflock with a "does not exist" precondition; the winner's lock ID is that object's GCS generation number, and unlocking is a delete conditioned on that generation. A second run finds the object present and blocks.

This has three consequences worth knowing before an incident:

  • terraform init itself takes a lock, so a stuck lock blocks initialization, not just apply.
  • terraform force-unlock requires the numeric generation, which the error message prints. There is no "unlock whatever is stuck" form, and its only option is -force.
  • A killed run leaves the lock object behind. Nothing expires it. A CI job cancelled mid-apply holds the lock until a human clears it.

Never habituate -lock=false. It exists, and it is the documented way to turn a slow pipeline into two concurrent applies writing the same state — which is the one failure mode from which state versioning may not save you, because both writes are valid objects.

The permission to unlock is the permission to write the lock object, which is the same roles/storage.objectAdmin the pipeline already holds. If you want an unlock to be a deliberate human act, that separation has to come from the pipeline's design, not from IAM.

Pitfall. Two root modules that share a prefix share a lock and a state. This is easy to do by copying a backend.tf between environment directories and forgetting the one line that differs, and the symptom is that staging's apply destroys production's resources because they are absent from the configuration it planned.

26.16 State Encryption §

Terraform has no client-side state encryption. State is encrypted because Cloud Storage encrypts it, and the control you have is over the key.

This is a real capability boundary, not a documentation gap. Feeding the same configuration to both binaries settles it:

$ terraform validate
Error: Unsupported block type
  Blocks of type "encryption" are not expected here.

$ tofu validate
Success! The configuration is valid.

OpenTofu's encryption {} block encrypts state and plan files before they leave the machine; §27.11 owns it. In Terraform the equivalent protections are the bucket's.

The backend offers two key options and they behave differently on rotation:

ArgumentKey typeChanging or removing the key
kms_encryption_keyCustomer-managed (CMEK)Terraform migrates state automatically
encryption_keyCustomer-supplied (CSEK)Manual intervention required

Use CMEK. The asymmetry above is decisive: HashiCorp documents that a customer-managed key can be changed or removed with Terraform managing the state migration, and that a customer-supplied key cannot. Key rotation is a compliance requirement (§29.8) and one of these options makes it a routine operation.

The Cloud Storage service agent needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key, granted on the specific key rather than the ring, following the pattern §14.8 owns.

Pitfall. Bucket-level encryption protects state at rest in the bucket. It does nothing for the copy of state on the CI runner's disk during an apply, or for the plan file the same run produced. The runner is inside the trust boundary and must be treated that way (§26.24).

26.17 Protecting Sensitive State §

§13.13 owns the mechanism that keeps secrets out of state — write-only arguments and ephemeral resources. This section owns the consequence: state is an asset, and it needs an asset's controls.

Assume state contains secrets even after you have done everything right. Google's list of resources that store secret values in plaintext is not exhaustive, providers add attributes, and a resource that was safe last quarter can start returning a computed credential. The controls therefore have to be positional rather than dependent on knowing what is in there.

The four controls, in order of how much they buy:

  1. Read access limited to the pipeline service account. Not to the engineering group, not to a broad viewer role. This is the only one that actually prevents disclosure.
  2. CMEK on the bucket (§26.16), giving you a revocation lever independent of the bucket's IAM.
  3. Data Access audit logs enabled on the bucket, so a read is a record. They are off by default (§33.11), and this bucket is the clearest case in the estate for turning them on.
  4. Object versioning, which is availability rather than confidentiality, and which also means an old secret persists in an old generation long after the resource is gone.

Write-only arguments genuinely solve the write side. Provider 8 carries them on sixteen resources, including google_secret_manager_secret_version, google_sql_user, google_sql_database_instance, google_compute_ssl_certificate, and google_compute_vpn_tunnel — which between them cover most of the ways a credential used to enter state.

Ephemeral resources solve the read side. ephemeral "google_client_config" and ephemeral "google_secret_manager_secret_version" return values that are never written to state or to a plan file, replacing the two data sources that most reliably leaked.

Pitfall. The plan file is the forgotten copy. It holds the same values as state, it is produced by the plan job, and it is routinely stored as a build artifact so the apply job can consume it (§26.24). Treat the plan artifact as a secret with the same retention and access rules as state, or the state bucket's careful IAM is bypassed by a build log.

26.18 Separate State per Environment §

One state per environment, one bucket prefix per state, one root module per environment. The alternative — CLI workspaces — is explicitly not recommended by Google, for reasons that are about failure domains rather than taste.

The argument against workspaces, in Google's terms: it is difficult to inspect the configuration in each workspace, and a shared backend across environments becomes a single point of failure for all of them. A workspace switch is a shell state, invisible in the repository and invisible in review.

What separation actually buys:

PropertySeparate root modulesWorkspaces
Blast radius of one applyOne environmentOne environment, if the operator was in the right workspace
Backend failure domainPer environmentShared
Per-environment provider versionIndependentShared configuration
Reviewable in a diffYes — the files differNo — the difference is a CLI flag

The prefix is what keeps them apart operationally, and it is one line that is easy to copy wrongly (§26.15). Make it derive from the directory name in your CI, or check it in review, because the failure is silent until it is catastrophic.

Production's state deserves a different bucket, not just a different prefix, once the estate is large enough to have a separate production boundary at all. A prefix separates objects; a bucket separates IAM, CMEK, audit configuration, and the retention policy.

Pitfall. Separate state does not by itself separate credentials. Three environments whose pipelines all federate into the same deploy account have three states and one blast radius. §26.4 pins one deploy account per environment for exactly this reason, and B01 fixed that convention.

26.19 Importing Existing Infrastructure §

Import brings an existing resource under Terraform's management by writing it into state. Google's advice is to avoid it where you can, and the reason is provenance: an imported resource's configuration is a reconstruction, and nobody can tell from the repository what was actually deliberate.

Prefer the import block over the terraform import command. The block is configuration, so the import is planned, reviewed, and applied like anything else, rather than being a one-off CLI action taken by whoever was on shift:

import {
  id = "projects/rc-saas-prod-net-01/global/networks/legacy-vpc"
  to = google_compute_network.legacy
}

The to address is required; id and identity are mutually exclusive. The block also accepts for_each, which makes a bulk import reviewable as one change.

Configuration generation is experimental and labeled as such. terraform plan -generate-config-out=generated.tf writes HCL for imported resources not already in configuration, and Terraform's documentation warns that later minor versions may change both the formatting and the behavior of the flag. Use it as a starting draft that a human edits, never as the committed artifact — it emits every attribute including defaults, and the result obscures which values were chosen.

gcloud beta resource-config bulk-export exports a whole project as HCL, with --resource-format=terraform. It is Preview, it requires the config-connector component that is not part of a stock SDK install, and it must not be a production dependency. It is a discovery aid for a migration, not a pipeline step.

After import, manage the resource exclusively with Terraform. A resource that is imported and then also edited by hand produces drift on every plan, and the team learns to ignore plan output (§26.22).

Pitfall. Import writes state without validating that your configuration matches the resource. A configuration that differs from what was imported produces a destroy-and-recreate on the next apply — of a resource that was, until that moment, in production and working.

26.20 Lifecycle Rules §

The lifecycle block changes how Terraform treats a resource's replacement and destruction. It is meta-configuration: it is evaluated by the core, not sent to any API.

ArgumentWhat it doesThe security use
prevent_destroyRejects a plan that would destroy thisStateful resources (§26.21)
create_before_destroyNew object first, then remove the oldAvoids a gap in a control during replacement
ignore_changesSuppresses diffs on named attributesCoexisting with an external manager
replace_triggered_byReplaces when a referenced resource changesForces rotation on a dependency change
precondition / postconditionAsserts an invariant at plan or applyA control expressed as an assertion

create_before_destroy propagates to dependencies, and Terraform records that propagation in state. A resource whose replacement would otherwise remove a firewall rule or an IAM binding before its replacement exists is the case that matters — the gap is short, and it is a gap in an enforcement point.

ignore_changes is the most abused argument here. It is correct when another system legitimately owns an attribute — a label written by an autoscaler, a field mutated by a managed service. It is a way to hide drift when someone adds it to stop a recurring diff, and the diff was telling the truth (§26.22).

Preconditions turn an assumption into a gate:

lifecycle {
  precondition {
    condition     = data.google_compute_network.shared.project == var.host_project_id
    error_message = "Refusing to attach to a VPC outside the designated host project."
  }
}

Pitfall. replace_triggered_by accepts only managed resource addresses. Pointing it at a local value or an input variable is a plan-time error, because Terraform decides from the referenced resource's planned action, which a plain value does not have.

26.21 Prevent Destroy §

prevent_destroy = true makes Terraform reject any plan that would destroy the resource. It is the cheapest guard on a stateful resource, and it has one gap large enough to be the whole story.

Removing the resource's configuration removes the protection with it. HashiCorp states this directly: Terraform does not record prevent_destroy in state, so if the resource block is deleted, the guard is deleted at the same time and the resource is destroyed on the next apply. The argument protects against a changed resource, not against a deleted configuration — which is precisely how resources actually get destroyed by accident.

So use both layers. Many google resources carry a provider-side or API-side protection that survives configuration deletion because it lives on the resource:

resource "google_sql_database_instance" "main" {
  name                = "rc-saas-prod-sql-01"
  region              = "us-central1"
  database_version    = "POSTGRES_16"
  deletion_protection = true

  lifecycle {
    prevent_destroy = true
  }
}

deletion_protection is checked by the API; prevent_destroy is checked by Terraform. An operator who deletes the resource block defeats the second and is still stopped by the first. An operator who runs gcloud sql instances delete defeats neither.

The provider also has a deletion_policy argument taking DELETE, PREVENT, or ABANDON, defaulting to DELETE, on resources such as projects and folders.

Use it sparingly, as HashiCorp advises: prevent_destroy makes some legitimate changes impossible to apply and blocks terraform destroy entirely for that configuration. Put it on data, not on everything.

Pitfall. A resource that must be replaced to change an immutable attribute cannot be, while prevent_destroy is set. The change looks like a Terraform bug, someone removes the guard to get the apply through, and it is not put back.

26.22 Drift Detection §

Drift is the difference between what state records and what the API reports. Detecting it is a scheduled job, not something an apply does for you.

terraform plan -detailed-exitcode is the whole mechanism, because it turns a diff into an exit status a pipeline can branch on:

Exit codeMeaning
0Succeeded, no changes
1Errored
2Succeeded, there is a diff
terraform plan -detailed-exitcode -lock=false -refresh-only

-refresh-only is the right mode for a detection job. It compares state against reality and proposes no actions, so a scheduled run cannot become an accidental apply. Pair it with -lock=false so the detector never blocks a real deployment — this is the one place that flag is defensible, because the job writes nothing.

Run it nightly against every environment and alert on exit code 2. Drift in production infrastructure is either an emergency change nobody wrote down, a manual fix that will be reverted by the next apply, or an attacker. All three deserve a page rather than a discovery three weeks later.

Drift you cannot see is the larger problem. Terraform only refreshes resources it knows about, so a firewall rule someone added by hand is not drift — it is invisible. Cloud Asset Inventory (§29.6) is what closes that gap, and Security Command Center (§16.5) is what alerts on the subset that is dangerous.

Pitfall. A drift detector that reports the same benign diff every night is a detector nobody reads. Fix the cause — usually a mutable attribute an external system owns, which belongs in ignore_changes (§26.20) — rather than lowering the alert's severity.

26.23 Terraform Plan Review §

The plan is the object under review. Everything the change-management process claims to control is decided by whether a human read this output and understood it.

Save the plan and apply that artifact. Google's guidance is to generate a plan, save it, have an infrastructure owner approve it, and then execute that plan. The alternative — plan on the pull request, apply from main later — approves one change and applies another:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

Read the plan for the things a diff makes small. A destroy is loud; these are quiet:

  • Any -/+ on a stateful resource. Replacement of a database is a destroy with extra steps.
  • A removed IAM binding, which reads as a deletion of two lines and is a change to who can do what.
  • A widened CIDR or a changed enforce value, where the diff is one character.
  • A resource moving between modules, which is a destroy and a create unless a moved block says otherwise.
  • A provider version change in the lock file, which changes behavior across every resource at once.

Machine-check the plan as well as reading it. §22.12 owns infrastructure code scanning and carries the generally available Security Command Center check that consumes exactly the JSON above.

Pitfall. terraform show -json output contains the same sensitive values as state, in plaintext. Posting it to a pull request comment for reviewability publishes secrets to everyone with repository read access — which is a much larger group than everyone with state read access (§26.17).

26.24 Terraform CI/CD §

Chapter 23 owns Cloud Build and §20.10 owns running a pipeline inside a service perimeter. This section owns the plan/apply workflow itself and the approval between the two halves.

The shape is two jobs, not one, and the seam between them is the control:

  1. Plan — runs on the pull request, with a read-oriented identity, and produces tfplan.binary as an artifact.
  2. Policy check — consumes tfplan.json (§22.12, §26.25) and fails the pull request on a violation.
  3. Approval — a human approves the pull request, having read the plan (§26.23).
  4. Apply — runs on merge, consumes the saved plan artifact, and creates nothing that was not in it.

Applying a saved plan needs no -auto-approve. Terraform applies a saved plan file without an interactive prompt, so a pipeline that reaches for -auto-approve is a pipeline re-planning at apply time — which is the failure the whole shape exists to prevent, because the applied change is then one nobody reviewed.

Split the identity across the two jobs. The plan job needs read access and the ability to write the lock and state; the apply job needs the permissions to make the change. Running plan with the apply identity means every pull request, including one from a fork, executes with production credentials.

The runner is inside the trust boundary. It holds the state, the plan artifact, and a token that can change the estate. §26.17's controls on state are undone by a build log that prints it, and §23.x's build hardening applies here in full.

Pitfall. The plan artifact must be immutable between approval and apply. A pipeline that stores it in a mutable location, or regenerates it in the apply job "to be safe", has an approval that refers to nothing. Pin the artifact to the commit and fail the apply if the plan does not match.

26.25 Policy Validation §

§22.12 owns infrastructure code scanning and carries the command. This section owns where the check sits in the plan/apply cycle and how to choose among the engines.

Validating a plan beats validating configuration, because the plan is the resolved change: variables are substituted, counts are expanded, and computed values are known where they can be. A rule written against configuration files sees var.cidr and cannot judge it.

Four checks, at three different times:

CheckWhenCatches
terraform fmt -checkPre-commitFormatting drift
terraform validatePre-commitSyntax and type errors
Variable validation blocks (§26.8)PlanValues your module refuses
Plan policy validationAfter plan, before approvalOrganizational and posture rules

The generally available Google mechanism is gcloud scc iac-validation-reports create, which takes a parent of the form organizations/ORG_ID/locations/global and the plan JSON, and returns a long-running operation. §22.12 shows the invocation.

gcloud beta terraform vet is the other Google option and it is Preview. It evaluates a plan against Cloud Foundation Toolkit policy constraints. Preview surfaces must not be a production dependency, so it can supplement the gate but must not be the gate.

Policy validation is not a substitute for organization policy. A constraint (§2.30, Chapter 31) rejects the resource at the API regardless of which tool created it; a plan check rejects it earlier and more helpfully, for changes that come through this pipeline. You want both, and if you can only have one, have the constraint.

Pitfall. A plan validated against a stale state describes a change that will not happen. §22.12 states this and it is worth repeating operationally: validate and apply the same plan artifact, or the report is about a hypothetical.

26.26 Module Versioning §

A module version is what stops a change to shared infrastructure code from reaching every environment at once.

version is legal only for registry sources. This is the single most common surprise:

Source formExampleversion allowed
Registryhashicorp/consul/awsYes
Local path./modules/networkNo — same repository, same version
Gitgit::https://…/vpc.git?ref=v1.2.0No — pin with ?ref=
Cloud Storagegcs::https://…/module.zipNo — pin with the object path

For a git source, ?ref= is the pin, and what you point it at decides how strong the pin is. A tag is readable and movable; a commit SHA is immutable and unreadable. Use a tag for modules your own team controls and a SHA for anything you do not.

Follow SemVer and constrain to a major version. Google's rule is that shared modules follow SemVer 2.0.0 and callers pin to the major. ~> 2.0 then takes fixes automatically and refuses the breaking change.

What makes a change breaking, in module terms: removing a variable, removing an output, changing a variable's type, or changing a default in a way that alters the resulting infrastructure. Adding a variable with a default is not.

Store shared modules where their access can be controlled. A module is code that runs with the pipeline's credentials; read access to the module registry is not the security question, write access is. gcs:: sources authenticate through the Cloud SDK, so a private bucket is a workable private registry with IAM you already understand.

Pitfall. A module pinned to a branch — ?ref=main — is not pinned. Every apply resolves to whatever that branch points at, so a merge to a shared module's default branch is a production change to every environment that references it, with no plan and no approval.

26.27 Reusable Organization Modules §

An organization module creates folders, projects, and the policy attached to them. Its design problem is opinionation: it is the one module that should refuse to be configurable.

A project factory that accepts every option is not a factory. The value of §2.26's pattern is that every project comes out the same — the same label set, the same APIs, the same baseline constraints, the same absence of a default network. Each variable you add is a way for a caller to produce a project that is not like the others, and the non-standard project is the one nobody audits.

So the interface is deliberately narrow: the project's name, its folder, its billing account, its owning group, and the values of the five mandatory labels (§2.26). Everything else is decided by the module.

What the module should assert rather than accept:

DecisionWhy it is not a variable
No default network§31.6 makes it a constraint anyway
The five mandatory labelsA project without them is invisible to cost and ownership review
Baseline API setAn unexpected enabled API is an unexpected attack surface
Deletion protection on the project§26.21

Return the project number as well as the ID. Half the estate's IAM — service agent addresses, principalSet members (§26.4) — is keyed by number, and a caller that has to look it up separately will hardcode it.

Pitfall. A project factory that also grants IAM inside the project couples two lifecycles: adding a team member becomes a change to the module that creates projects. Create the project here, grant into it with §26.29's module, and the blast radius of an IAM change stops at IAM.

26.28 Reusable Network Modules §

A network module creates VPCs and subnets. Its design problem is address allocation, and the correct answer is that the module must not do it.

Ranges are an estate-wide decision and belong to the caller. A module that computes a subnet range from a base CIDR and an index is convenient exactly until two callers use the same base, or until someone inserts an environment in the middle and every subsequent range shifts. §5.4's plan lives in decisions.md for this reason: it is a register, not a formula.

Take the ranges as input and validate them:

variable "subnets" {
  type = map(object({
    region            = string
    primary_range     = string
    secondary_ranges  = optional(map(string), {})
    flow_log_sampling = optional(number, 0.5)
  }))
}

The full module is in code/tf/network/.

Secondary ranges must be inputs too, because a GKE caller's pod and service ranges bound its cluster's capacity permanently (§9.7) and a range in use cannot be removed.

Set the security-relevant fields and do not expose them. Private Google Access on (§5.17), flow logs on, auto_create_subnetworks = false, and Cloud NGFW evaluation before legacy rules (§5.13). A caller has no legitimate reason to turn any of these off, and a variable that permits it will eventually be set.

Output the subnet IDs and the secondary range names, not just the ranges. GKE consumes range names; a caller that has both the module's output and its own copy of the name will eventually have two different names.

Pitfall. Deleting a subnet from the subnets map destroys it, and a subnet with anything attached cannot be destroyed — so the apply fails halfway, after other changes have landed. Removing network capacity is a multi-step operation, not a map edit.

26.29 Reusable IAM Modules §

An IAM module grants roles. Its design problem is that Terraform offers three resource families for the same job and two of them destroy bindings they cannot see.

The three families, and why only one belongs in a shared module:

ResourceScope of authoritySafe in a module
google_project_iam_memberOne principal, one roleYes
google_project_iam_bindingAll principals for one roleNo
google_project_iam_policyThe entire project policyNever

An authoritative resource removes what it does not know about, and in a real project that includes bindings Google's own service agents create automatically — the SCC notification agent (§16.15), the Cloud Storage service agent holding a CMEK grant (§14.8). The provider documentation warns that google_project_iam_policy can lock you out of the project entirely, and B01 already fixed the book's position: prefer _member, name _policy only as an anti-pattern (§3.13).

Flatten the interface so one binding is one resource instance. Taking map(role, list(principal)) and flattening to one google_project_iam_member per pair means adding a principal creates one resource rather than replacing a role's whole binding. code/tf/project-iam/ shows the flattening.

Validate what the module refuses to grant. Basic roles and public principals are both rejected at plan time by a validation block (§26.8), which is a control that runs before a reviewer has to notice a role ID in a diff.

Bind groups, never users. §3.4 owns the argument; the module enforces it by refusing anything that is not a group principal, which turns a policy into a plan-time error.

Pitfall. Deny policies (§3.17) are a separate resource family with separate semantics and they are evaluated before allows. A module that manages only allow bindings gives an incomplete picture of effective access, and the person reading its inputs will believe otherwise.

26.30 Reusable Compute Modules §

A compute module creates instance templates and managed instance groups. Its design problem is that instance templates are immutable, so every change to one is a replacement.

A template is replaced, not updated, and the managed instance group referencing it must be updated to point at the new one. This makes create_before_destroy (§26.20) mandatory rather than optional: without it, Terraform destroys the template the running group still references.

resource "google_compute_instance_template" "main" {
  name_prefix = "${var.name_prefix}-tpl-"

  lifecycle {
    create_before_destroy = true
  }
}

name_prefix rather than name is the other half. Two templates cannot share a name, so create_before_destroy with a fixed name fails on the create. The prefix lets Terraform generate a unique suffix and the pattern works.

The module should own the security fields of the template and expose almost none of them: Shielded VM on (§8.16), no external IP, a dedicated service account rather than the default compute account (§33.7), OS Login through metadata (§8.19), and a boot disk CMEK key. The caller chooses the machine type, the image, and the size of the group.

The image is the interesting variable. Take an image family (§8.33) rather than a specific image and the group picks up new golden images; take a specific image and every rebuild is a deliberate act. Both are defensible; the module should force the caller to say which, not default to one.

Pitfall. Changing the template triggers a rolling replacement of every instance in the group, which is a production change disguised as a Terraform diff of two lines. The update policy — surge, unavailability, and whether the rollout is automatic at all — belongs in the module's interface, because the default is to start replacing immediately.

26.31 Reusable GKE Modules §

A GKE module creates a cluster and its node pools. Its design problem is scale: google_container_cluster has more configurable surface than any other resource in the provider, and a module that exposes it all is worse than no module.

A module's job here is to remove choices, not to pass them through. Chapter 9 establishes the cluster baseline. The module's contract is that a cluster built by it satisfies that baseline, and a variable for any baseline setting breaks the contract:

Fixed by the moduleChosen by the caller
Workload Identity enabled (§9.11)Cluster name and region
Shielded nodes, Secure Boot, integrity monitoringNode count and machine type
Private nodes; DNS-based control-plane access (§9.30)Authorized network ranges
Release channel set, node auto-upgrade onWhich channel
Dedicated node service account (§9.31)Which subnet and secondary ranges

Create the node service account inside the module. §9.31 fixes sa-gke-node-<cluster> holding only logging, monitoring, resource metadata, and Artifact Registry reader. If the caller supplies the account, the caller can supply one with too much, and the module's guarantee evaporates.

Separate the cluster module from the node pool module. Node pools change far more often than clusters, they are replaced rather than updated for most changes, and coupling them means a node pool edit re-plans the cluster.

Do not re-teach GKE in the module's README. Chapter 9 is forty sections; the README says which of its decisions the module has already made and links to them by number.

Pitfall. google_container_cluster creates a default node pool that the recommended pattern immediately removes. A module that forgets remove_default_node_pool and its required initial_node_count leaves an unmanaged pool running with settings the module never applied — including, historically, the default compute service account.

26.32 Reusable Cloud Run Modules §

A Cloud Run module deploys a service. Its design problem is the opposite of GKE's: the resource is small enough that a module can easily be a thin wrapper that adds nothing.

A wrapper module is a liability. If the module's variables map one-to-one onto the resource's arguments, it has added an indirection, a version to pin, and a place for the two to drift, in exchange for nothing. Either the module makes decisions or it should not exist.

The decisions worth making here are the ones §10.26 identifies as the ones teams get wrong:

  • Ingress is internal-and-cloud-load-balancing unless the caller argues otherwise, and the module's variable is a constrained enum, not a free string.
  • A dedicated sa-run-<service> account per service, created by the module. Never the default compute account.
  • --no-allow-unauthenticated equivalent by construction: the module creates no public invoker binding, and a caller wanting one must add it outside the module, where it is visible in review (§10.12 is the book's one permitted allUsers binding).
  • Secrets mounted from Secret Manager by reference (§13.9), never passed as environment variable values, which would put them in state.

Pin the image by digest, not tag. The module should reject a tag reference in a validation block, because §24.5 and §25.12 both depend on the deployed reference being immutable and this is the last place to enforce it cheaply.

Pitfall. Cloud Run resolves latest at revision start (§10.22), so a module that accepts a tag produces a service whose running code is not the code the plan described. The plan is clean, the apply succeeds, and the deployed artifact is whatever the tag pointed at that second.

26.33 Reusable Database Modules §

A database module creates Cloud SQL, AlloyDB, or Spanner instances. Its design problem is that these are the resources where a mistaken destroy is unrecoverable.

Everything else in this chapter is replaceable; this is not. A VPC can be rebuilt from configuration, a cluster can be recreated, a bucket can be repopulated. A destroyed database instance is a restore from backup at best, and §33.19 is about the estates where that turns out not to be true.

So the module's defaults are the conservative ones, and several of them are not the resource's defaults:

SettingModule defaultWhy
deletion_protectiontrueSurvives configuration deletion (§26.21)
lifecycle { prevent_destroy }trueRejects the plan earlier
Public IPDisabled§33.2
Backups and point-in-time recoveryEnabled§29.9 needs the evidence
CMEKRequired inputCannot be added later

CMEK is creation-time only on Cloud SQL and cannot be retrofitted. That makes it a required variable rather than an optional one with a default: a caller who omits it gets an instance that can never have it, and discovering that later means a migration.

Never generate the password in Terraform. Use password_wo with its _version counter (§13.13) so the value never enters state, and return the Secret Manager resource name rather than the credential (§26.9).

Pitfall. prevent_destroy blocks the replacement that an immutable-attribute change requires, so a legitimate change fails with an error that looks like a bug (§26.21). The team's instinct is to remove the guard for one apply. Document the intended procedure in the module's README — take a backup, remove the guard, apply, restore the guard — or the guard will be removed permanently the first time it is inconvenient.

26.34 Reusable Security Modules §

A security module creates the controls: organization policies, key rings and keys, log sinks, perimeters, and the audit configuration. Its design problem is that it must not be optional.

Every other module in this chapter is opt-in. This one has to be opt-out, and the opt-out has to be visible. A landing zone where the security module is a module call anyone can delete is a landing zone whose controls are one pull request from gone.

Two mechanisms make it structural rather than conventional:

  1. Apply it from a higher scope than the thing it protects. An organization policy set at the organization or folder (§2.31) is not in the project's root module and cannot be removed by editing it. The workload team's configuration cannot delete a control it does not own.
  2. Make its removal fail a check. §26.25's plan validation and the organization policies themselves both reject the resulting resources, so deleting the module produces a broken apply rather than a quietly weakened environment.

Key material has its own placement rule. §2.21 puts key rings in the security project and §14.8 fixes the naming and the administration split. The module creates the key and grants the consuming service agent roles/cloudkms.cryptoKeyEncrypterDecrypter on that specific key — never the ring, never the project — and grants no human roles/cloudkms.admin at all.

A security module's outputs are references, not secrets. It returns key names, sink writer identities, and perimeter names. A module that returns key material has made every caller's state a copy of it (§26.17).

Pitfall. A security module that manages the audit configuration with an authoritative resource fights every other configuration touching the same policy (§26.29), and the loser is whichever ran last. Audit config is the case where authoritative is genuinely wanted — so it belongs in exactly one configuration, at the organization, and that has to be a written decision rather than an accident.

26.35 Terraform Anti-Patterns §

The patterns below recur across estates, and each has a specific failure rather than a stylistic objection.

Anti-patternWhat actually goes wrong
google_project_iam_policy in shared codeRemoves service agent bindings; can lock you out of the project
Committed stateA secret in Git history, which deleting the file does not fix (§33.15)
terraform apply -auto-approve in a pipelineApplies a change nobody planned (§26.24)
-target as routine practiceTerraform's own help calls it "for exceptional use only"
CLI workspaces for environmentsOne backend, one failure domain (§26.18)
?ref=main on a moduleNot a pin; a merge is an unreviewed production change
local-exec provisionersCreates state Terraform does not track, so it is invisible to plan
Manual state editingCorruption is unrecoverable; use terraform state subcommands
ignore_changes to silence a diffHides drift that was telling the truth (§26.22)
Secrets in outputsCopies the secret into every consumer's state (§26.9)

local-exec deserves the longest note. Google's position is to avoid custom scripts and use them only where no resource supports the behavior, with a documented reason and a deprecation plan. The reason is not tidiness: a gcloud call in a provisioner creates real infrastructure that no plan will ever show, no drift detector will ever compare, and no destroy will ever remove. It is manual change with a Terraform-shaped wrapper.

The data sources that leak are worth naming individually. Google lists google_service_account_key and google_client_config among resources that store secrets in state in plaintext. Provider 8 has ephemeral equivalents for both (§26.11), which is the fix — not encrypting state harder.

The anti-pattern that is hardest to see is a plan nobody reads. Every control in this chapter — the policy check, the approval, the saved artifact — assumes a human looks at the diff. A team that merges infrastructure changes on a green check has automated the ceremony and removed the control, and no gate will report it.

Pitfall. Pinning everything and never reviewing the pins is its own anti-pattern. Google pairs "pin to a minor version" with "regularly review version pins" and suggests automating the review. A provider pinned three years ago is a provider with three years of unpatched behavior, and the lock file makes it invisible.

Chapter Summary §

  • Terraform's repository is a tier-zero system: merge access changes any control, and read access to state discloses much of what those controls protect.
  • A plan is the difference between configuration and refreshed state, so a resource Terraform does not know about is invisible to it, not drift.
  • Pin hashicorp/google at ~> 8.0, declare providers only in root modules, and commit .terraform.lock.hcl as the supply chain control it is.
  • Prefer Workload Identity Federation and impersonation over any downloaded key; access_token and external_credentials cannot be renewed and expire in about an hour.
  • A CI run needs roles/iam.workloadIdentityUser to become an account and roles/iam.serviceAccountUser to attach one; the second is the grant everyone forgets.
  • Pin federation attribute conditions to numeric repository IDs, never to names.
  • One root module and one backend per environment; Google recommends against CLI workspaces for environment separation.
  • State is plaintext JSON containing every attribute the provider read or wrote, and the plan file contains the same values.
  • gcloud storage buckets create has no --versioning flag — enable it with a second update call.
  • The gcs backend locks with a .tflock object whose GCS generation is the lock ID; a killed run leaves it behind and nothing expires it.
  • Terraform has no client-side state encryption; that is an OpenTofu capability (§27.11). Use CMEK on the bucket, because customer-managed keys can be rotated with automatic state migration and customer-supplied keys cannot.
  • Provider 8 carries write-only arguments on sixteen resources and six ephemeral resources, which together close both the write and read paths that used to put secrets in state.
  • prevent_destroy does not survive deletion of the resource block; pair it with the resource's own deletion_protection.
  • Detect drift on a schedule with terraform plan -refresh-only -detailed-exitcode and alert on exit code 2.
  • Generate a plan, review it, approve it, and apply that artifact; a pipeline needing -auto-approve is re-planning at apply time.
  • version on a module is legal only for registry sources; a git source pins with ?ref=, and ?ref=main is not a pin.
  • A shared module configures no provider and no backend, exposes an output for every resource, and sets disable_on_destroy = false on any API it enables.
  • Authoritative IAM resources remove bindings they cannot see, including Google service agents' own; use google_project_iam_member.
  • gcloud beta resource-config bulk-export is Preview, needs the config-connector component, and must not be a production dependency.

Security Checklist §

ControlWhy it mattersHow to verify (CLI + Console)
No service account key in any credential pathA downloaded key is a permanent, exfiltratable credentialgcloud iam service-accounts keys list --iam-account=SA --managed-by=user
Pipeline federates rather than authenticates§26.4; removes the key from CI entirelyWorkload identity pool provider's attribute condition
Attribute condition pins a numeric repository IDA name can be renamed or re-registeredgcloud iam workload-identity-pools providers describe PROVIDER
State bucket has uniform bucket-level access and public access preventionRemoves per-object ACLs and makes §33.1 impossible heregcloud storage buckets describe gs://BUCKET
State bucket has object versioningThe only recovery from a corrupt state writegcloud storage buckets describe gs://BUCKET --format='value(versioning)'
State bucket uses CMEKGives an independent revocation lever, rotatable without manual migrationBucket default_kms_key; key IAM in the security project
Data Access audit logs enabled on the state bucketA read of state is a read of secrets, and should be a recordProject IAM policy auditConfigs for storage.googleapis.com
State readable only by the pipeline accountThe one control that actually prevents disclosuregcloud storage buckets get-iam-policy gs://BUCKET
.gitignore excludes *.tfstate* before the first applyState in Git history is a secret in Git historyRepository .gitignore, and a history scan
Plan artifact treated as a secretIt holds the same plaintext values as stateBuild artifact retention and access policy
Apply consumes the approved plan artifactOtherwise the approval refers to a different changePipeline definition: apply tfplan, no -auto-approve
Plan job and apply job hold different identitiesA pull request must not run with production credentialsTwo service accounts in the pipeline definition
Plan validated by gcloud scc iac-validation-reportsCatches posture violations before approval (§22.12)Pipeline step and its failure behavior
Provider pinned ~> 8.0 and lock file committedReproducible provider binary; a supply chain control.terraform.lock.hcl present and in the diff
Module sources pinned by tag or digest, never a branch?ref=main makes a merge a production changegrep -r 'source *=' terraform/
Stateful resources carry both deletion_protection and prevent_destroyOne survives config deletion, the other rejects the planResource definition; §26.21
Drift detection runs nightly and alerts on exit code 2Undetected drift is an unrecorded change or an attackerScheduled job and its alert policy (§18.15)
No google_project_iam_policy outside §3.13's anti-patternIt removes service agent bindings and can lock you outgrep -r google_project_iam_policy
No local-exec without a documented reasonCreates infrastructure no plan or drift check can seegrep -r local-exec
Secrets use write-only arguments or ephemeral resourcesKeeps the value out of state and out of the plan filegrep -rE '_wo(_version)? *=' terraform/

Sources §