Terraform on Google Cloud

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Google Cloud is the cloud Terraform is most often paired with outside AWS and Azure, and it is the one Google itself documents most heavily: there is a Google-maintained module collection, a managed Terraform service, and a set of published best practices. This page is the single place in this section where all of that comes together. It follows Providers and Backends and Remote State in the reading path, and it is the Google Cloud counterpart of Terraform on AWS and Terraform on Azure.

The baseline is Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13, with the hashicorp/google and hashicorp/google-beta providers at their current major version, 8 (8.0.0 was released on 2026-08-26 and 8.8.0 on 2026-10-21). All values were checked against the provider’s own documentation and changelog, the Google Cloud documentation and the module repositories on 2026-10-23. Provider releases are weekly, so confirm the newest 8.x with terraform init -upgrade and the lock file before copying a constraint.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket, network, DNS, secrets and IAM). Project IDs, regions, service accounts and bucket names are placeholders (my-project-id, us-central1, bookshelf-example), and no credential, key or state of a real account appears anywhere. Secrets enter through TF_VAR_ variables, Secret Manager or federated identity, never through files in the repository.

What this page covers, and what it leaves to the Google Cloud section

The Google Cloud section of this site already documents the services themselves, and one page there carries a small Terraform baseline. This page does not repeat either. It adds what only Terraform users need.

Topic Where it lives

The Bookshelf baseline: an Artifact Registry repository, a runtime service account and a first Cloud Run service, plus the first look at Infrastructure Manager, Deployment Manager shutdown, Config Connector and resource-config bulk-export

Infrastructure as Code on Google Cloud. Read it first; this page builds on it and does not duplicate the baseline.

Cloud Run, Cloud Run functions, Cloud SQL, Cloud Storage, VPC, Secret Manager and KMS, IAM, logging and monitoring, DNS

Cloud Run, Cloud Run functions, Cloud SQL, Cloud Storage, VPC networking, Secret Manager and KMS, Identity and Access, Monitoring and observability

Projects, folders and the organization that hold everything below

Resource hierarchy and projects

Pipelines, plan comments, OIDC trust and environment gates in GitHub Actions

CI/CD and Automation; only the Google-specific points are added here

Keeping secrets out of state with ephemeral values and write-only arguments

Sensitive Data, Ephemeral Resources and Write-only Arguments

Preparing a project

Terraform on Google Cloud always runs against a project. The project holds the resources, the quota and the billing link, and most provider resources take a project argument that falls back to the provider default. Creating the project (and its folder and organization) is a platform task that usually has its own configuration; see Resource hierarchy and projects. Everything on this page assumes an existing, billing-enabled project for the Bookshelf, ideally one project per environment.

Almost every Google Cloud service must be enabled before its API can be called, and the google_project_service resource does this. Its arguments are service (required), project, disable_on_destroy, disable_dependent_services, check_if_service_has_usage_on_destroy and deletion_policy.

variable "project_id" {
  type = string
}

locals {
  bookshelf_apis = [
    "artifactregistry.googleapis.com",
    "bigquery.googleapis.com",
    "cloudkms.googleapis.com",
    "cloudresourcemanager.googleapis.com",
    "compute.googleapis.com",
    "eventarc.googleapis.com",
    "iam.googleapis.com",
    "iamcredentials.googleapis.com",
    "logging.googleapis.com",
    "monitoring.googleapis.com",
    "pubsub.googleapis.com",
    "run.googleapis.com",
    "secretmanager.googleapis.com",
    "servicenetworking.googleapis.com",
    "sqladmin.googleapis.com",
    "storage.googleapis.com",
  ]
}

resource "google_project_service" "bookshelf" {
  for_each = toset(local.bookshelf_apis)

  project                    = var.project_id
  service                    = each.key
  disable_on_destroy         = false
  disable_dependent_services = false
}

Three details matter in practice:

  • The default of disable_on_destroy changed in 7.0.0. It used to be true, which meant that destroying one google_project_service resource switched the API off for the whole project and could break unrelated workloads. Since 7.0.0 it has no default: destroying the resource only forgets it, and you must set disable_on_destroy = true explicitly to get the old behaviour. Setting it to false, as above, makes the intent visible. Google’s own module guidance goes further and says a module that activates APIs must set the equivalent option to false, because several instances of the module would otherwise disable each other’s services.

  • Enabling an API takes a moment to propagate. A resource that is created immediately afterwards can fail with a message that the API has not been used or is disabled. Give each resource a depends_on on the relevant google_project_service entry, or enable APIs in an earlier configuration (see the layered layout later on this page).

  • API calls are billed and rate-limited per project. The provider can group parallel google_project_service and IAM requests with its batching block; leave it alone unless you are certain you need it, as the provider reference advises.

The same page of the provider reference is also where Google’s quota rules appear. Service account credentials are associated with the project the account lives in, while credentials from gcloud are associated with a project owned by Google. The documented recommendation is to set the provider argument user_project_override to true for the latter, and then give billing_project (or the environment variable GOOGLE_BILLING_PROJECT) so that quota and billing are charged to your project and the caller needs serviceusage.services.use on it.

The google and google-beta providers

HashiCorp maintains two Google Cloud providers from the same code base. hashicorp/google supports generally available products and features. hashicorp/google-beta supports those plus Preview ones, and sends every request to the beta endpoint where one exists. The documentation is shared: fields and resources that exist only in the beta provider are marked in the provider reference. Using google-beta is comparable to using gcloud beta instead of gcloud.

You switch a single resource to the beta provider with its provider meta-argument. If you omit the argument, Terraform silently uses google, even when only a google-beta provider block exists. Both providers accept the same configuration arguments, but each needs its own block.

terraform {
  required_version = ">= 1.11"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.8"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "~> 8.8"
    }
  }
}

variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-central1"
}

variable "environment" {
  type    = string
  default = "dev"
}

provider "google" {
  project = var.project_id
  region  = var.region

  default_labels = {
    application = "bookshelf"
    environment = var.environment
    managed-by  = "terraform"
  }
}

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

# A resource that needs a Preview feature opts in explicitly:
# resource "google_compute_instance" "example" {
#   provider = google-beta
#   ...
# }

The provider block takes defaults that most resources inherit, and a few arguments that change how the provider behaves.

Argument (environment variable) What it does

project (GOOGLE_PROJECT, GOOGLE_CLOUD_PROJECT, GCLOUD_PROJECT, CLOUDSDK_CORE_PROJECT)

The default project. A project argument on a resource takes precedence.

region and zone (GOOGLE_REGION, GOOGLE_ZONE, and CLOUDSDK_COMPUTE_REGION, CLOUDSDK_COMPUTE_ZONE)

The default region and zone for regional and zonal resources. The zone should be inside the region.

credentials, access_token, impersonate_service_account, impersonate_service_account_delegates, external_credentials

Authentication, described in the next section.

default_labels

Labels applied to every resource that has a top-level labels field. A label set on a resource overrides the default with the same key. Both end up in the computed terraform_labels and effective_labels attributes, so a label added by Google or by another tool does not produce a permanent diff on labels.

add_terraform_attribution_label and terraform_attribution_label_addition_strategy

Add the label goog-terraform-provisioned = true to resources, so they can be recognised in the console and gcloud. The label was introduced in 5.16.0 as opt-in, is added to newly created resources by default since 6.0.0, and the strategy PROACTIVE also adds it to existing resources on the next apply (CREATION_ONLY is the default).

user_project_override, billing_project

Choose which project is charged for quota and billing, see the previous section.

request_timeout, poll_interval, request_reason

The wait for one HTTP request (default 120 seconds), the pause between polls of a long-running operation (default 10 seconds; raising throughput is better done with -parallelism), and a justification sent in the X-Goog-Request-Reason header and recorded in the audit logs.

deletion_policy

A provider-wide default of DELETE, PREVENT or ABANDON. A deletion_policy set on a resource takes precedence.

batching

Groups parallel google_project_service and IAM requests. Avoid custom values unless needed.

{service}_custom_endpoint

Overrides the endpoint of one API, for example compute_custom_endpoint, for emulators or private endpoints. Best effort.

Two habits keep upgrades uneventful. First, commit .terraform.lock.hcl and constrain the minor version: Google’s own best practice for root modules is to pin each provider to a minor version and let patch releases flow in, which corresponds to a constraint like ~> 8.8.0. The ~> 8.8 used on this page accepts newer minors of the same major as well, which suits a documentation example, but a team that wants every provider change to be a reviewed pull request should use the tighter form together with a dependency bot. Second, treat a major version as a project: read the upgrade guide first, as described in the next subsection.

Upgrading across the 6, 7 and 8 majors

The provider ships a numbered upgrade guide for every major version. The guides say, in the same words each time, to upgrade to the last release of the previous major first, fix every deprecation warning, run terraform plan until it is clean, and only then raise the constraint. They also explain how to go back: if you ran only init or plan, downgrading the provider is enough; after refresh or apply, restore the previous state version from a versioned backend such as the gcs bucket described below.

Major Released Changes that commonly touch a Bookshelf-style configuration

6.0.0

2024-08-26

goog-terraform-provisioned is added to new resources by default. deletion_protection appears, enabled by default, on google_cloud_run_v2_service and google_cloud_run_v2_job, and deletion_policy on google_project now defaults to PREVENT (the old skip_delete is gone: use ABANDON). containers.env of Cloud Run becomes a set, so it can no longer be indexed by position. A removed liveness_probe is now really removed from the service. On google_sql_database_instance, settings.ip_configuration.require_ssl is removed in favour of ssl_mode (removal landed in 6.0.1). name_prefix limits grow for several Compute resources.

7.0.0

2025-08-26

disable_on_destroy of google_project_service loses its default of true. Import IDs are validated more strictly. deletion_protection is added to google_alloydb_cluster, and deletion_policy of Secure Source Manager resources defaults to PREVENT. event_trigger.event_type becomes required on google_cloudfunctions2_function, and service_config.service is output-only. google_sql_user requires password_wo and password_wo_version together. The retention_period of google_storage_bucket becomes a string. The topic format of google_storage_notification changes. enable_flow_logs of google_compute_subnetwork is removed (use log_config). google_artifact_registry_repository loses the defaults of the public_repository fields.

8.0.0

2026-08-26

Removals of products that were shut down: BeyondCorp app connection, connector and gateway resources, google_iap_brand and google_iap_client, google_ml_engine_model, the Vertex AI Workbench notebooks resources (use google_workbench_instance) and google_vertex_ai_schedule (use google_colab_schedule). google_secret_manager_secret_version now requires secret_data_wo_version whenever secret_data_wo is set, and the version fields are strings. The default load_balancing_scheme of google_compute_backend_service and google_compute_global_forwarding_rule changes from EXTERNAL to EXTERNAL_MANAGED. source_contents is required on google_workflows_workflow. google_bigquery_dataset no longer computes default_collation.

The 7.x series still receives patches, and the Cloud Foundation Toolkit modules accept both 7 and 8 (their constraint is >= 4.64, < 9 for the network module), so a staged upgrade is realistic. The Cloud Foundation Fabric modules, on the other hand, require >= 8.6.0, < 9.0.0 in their current release. Combine modules from both collections only after checking that their provider ranges overlap.

Authentication

The provider and the backend both authenticate with Application Default Credentials (ADC). ADC is the library strategy that looks for credentials in a fixed order: the GOOGLE_APPLICATION_CREDENTIALS environment variable, then the local file created by gcloud auth application-default login, then the service account attached to the compute resource that is running the code. Google documents ADC as the recommended way to authenticate Terraform, because the same configuration then runs unchanged on a laptop, in Cloud Shell and in production. The provider also has its own explicit arguments, which take precedence when set.

Where Terraform runs Recommended method Notes

A workstation

gcloud auth application-default login, optionally with impersonation of a deployer service account

User ADC credentials expire and are refreshed by logging in again. The command is separate from gcloud auth login.

Cloud Shell or Cloud Build

The identity of the session or the build’s service account

Cloud Shell already has Terraform and credentials.

A VM, GKE node or Cloud Run job on Google Cloud

An attached user-managed service account

Do not use the default compute service account: it is highly privileged. Make sure the cloud-platform scope is present.

GitHub Actions, GitLab CI, other OIDC issuers

Workload Identity Federation

No key file exists. See the section below and CI/CD and Automation.

HCP Terraform

Dynamic provider credentials, which are federation under the hood

See HCP Terraform and Terraform Enterprise.

Anywhere else

An external credential configuration file for the federation, or as a last resort a service account key

A key is a long-lived password. If one is unavoidable, store it in a secret manager, never in the repository.

Local development with ADC

On a workstation, install the Google Cloud CLI, run gcloud init, and create the ADC file once. Nothing is written into the Terraform directory.

gcloud auth application-default login

# Optional: tell Terraform which project pays for quota when using user credentials
export GOOGLE_PROJECT="my-project-id"
export GOOGLE_BILLING_PROJECT="my-project-id"
export USER_PROJECT_OVERRIDE=true

terraform init
terraform plan -out=tfplan

Impersonating a deployer service account

A person should not run production changes with their own broad permissions. The usual pattern is to give each environment a deployer service account that holds the roles Terraform needs, and to let a small group of people (and the CI identity) act as it. Terraform then runs as that account while the caller’s own identity only needs one role on it, roles/iam.serviceAccountTokenCreator. Permissions and quotas are evaluated against the impersonated account. You can configure it in three equivalent ways:

  • with the provider argument impersonate_service_account (and impersonate_service_account_delegates for a delegation chain);

  • with the environment variable GOOGLE_IMPERSONATE_SERVICE_ACCOUNT, which is the best choice when the same configuration is run by different identities in different places;

  • with gcloud auth application-default login --impersonate-service-account=EMAIL, which writes an ADC file that already impersonates the account, so neither the provider block nor the backend block need to mention it.

variable "project_id" {
  type = string
}

resource "google_service_account" "deployer" {
  project      = var.project_id
  account_id   = "bookshelf-deployer"
  display_name = "Bookshelf Terraform deployer"
}

# Narrow roles: grant only what the configuration manages. This one lets the
# deployer manage Cloud Run services; add others per layer.
resource "google_project_iam_member" "deployer_run_admin" {
  project = var.project_id
  role    = "roles/run.admin"
  member  = google_service_account.deployer.member
}

# Who may act as the deployer: a group of platform engineers, not individuals.
resource "google_service_account_iam_member" "deployer_token_creator" {
  service_account_id = google_service_account.deployer.name
  role               = "roles/iam.serviceAccountTokenCreator"
  member             = "group:bookshelf-platform@example.com"
}
export GOOGLE_IMPERSONATE_SERVICE_ACCOUNT="bookshelf-deployer@my-project-id.iam.gserviceaccount.com"
terraform plan

The provider reference also describes a variation worth knowing: an ephemeral access token. The google_service_account_access_token ephemeral resource mints a short-lived token for a target service account, and a second, aliased google provider block can take it as access_token. The token never reaches the state or the plan, which makes it a good fit when one configuration must act as several service accounts. Because Terraform cannot renew an access token, a very long apply can outlive it (the default lifetime is one hour), and the documentation recommends credentials that can be refreshed in that case.

provider "google" {
  # Bootstrap identity: ADC of the person or pipeline running Terraform.
}

ephemeral "google_service_account_access_token" "network_admin" {
  provider               = google
  target_service_account = "bookshelf-network@my-project-id.iam.gserviceaccount.com"
  scopes                 = ["cloud-platform"]
  lifetime               = "1800s"
}

provider "google" {
  alias        = "network"
  access_token = ephemeral.google_service_account_access_token.network_admin.access_token
}

Workload Identity Federation from CI

A pipeline running on GitHub, GitLab or another OIDC issuer should not hold a service account key. Instead, Google Cloud trusts the issuer through a workload identity pool and a provider in that pool, and the pipeline exchanges its short-lived OIDC token for Google credentials. The Terraform resources for the pool and provider (google_iam_workload_identity_pool, google_iam_workload_identity_pool_provider) and the roles/iam.workloadIdentityUser binding, together with the mandatory attribute condition that stops other repositories from using the trust, are shown in the Google Cloud part of CI/CD and Automation, and the Google Cloud side of the concept is explained in Identity and Access. They are not repeated here.

What belongs on this page is how the resulting credentials meet Terraform. The google-github-actions/auth action (major version 3 at the time of writing) exchanges the OIDC token and, by default, writes a credentials file and exports GOOGLE_APPLICATION_CREDENTIALS. Both the provider and the gcs backend read ADC, so no further Terraform configuration is needed. The workflow needs id-token: write, and the checkout step must run before the auth step.

name: terraform-plan
on:
  pull_request:

permissions:
  contents: read
  id-token: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ vars.GCP_TERRAFORM_SERVICE_ACCOUNT }}

      - uses: hashicorp/setup-terraform@v4

      - run: terraform init -input=false
      - run: terraform plan -input=false -lock-timeout=5m

With service_account set, the action uses federation through a service account, which needs roles/iam.workloadIdentityUser on it; without it, it uses direct federation and the pool principal itself receives roles on the resources. The action’s README warns that the GitHub token lives five minutes, so derived credentials last five minutes too, and that changes to pools and providers can take a few minutes to propagate.

The provider also supports federation without a credentials file. Its external_credentials block (added in 6.28.0) takes an audience, a service_account_email and an identity_token, builds a federated credential from the token, and takes precedence over credentials and access_token. It was designed for HCP Terraform Stacks, where the platform hands the configuration an identity token, and the provider has a guide for that case; see Terraform Stacks. Like an access token, the credential built from it cannot be renewed, and the documentation suggests a credentials file with a federation configuration for runs that last longer than the token.

Pitfalls of credentials

  • The environment variable GOOGLE_CREDENTIALS is read by both the provider and the gcs backend. If the state bucket and the infrastructure need different identities, set GOOGLE_BACKEND_CREDENTIALS (or the backend’s own impersonate_service_account) for the backend.

  • Service account keys end up in places they should not: a google_service_account_key resource stores the private key in state, which is why Google’s security guidance lists it, together with tls_private_key and google_client_config, among the resources that keep secrets in plain text in state.

  • A 403 shortly after granting a role is often eventual consistency. The gcs backend documentation says IAM changes to buckets can take a few minutes to take effect, during which Terraform reports 403 errors.

The gcs backend

The gcs backend stores the state as an object in a Cloud Storage bucket and supports locking. The bucket must exist before the backend is configured, which creates a bootstrap problem solved below. The Terraform documentation puts the recommendation first: turn on Object Versioning on the bucket, so an accidental deletion or a bad write can be undone.

terraform {
  required_version = ">= 1.11"

  backend "gcs" {
    bucket = "bookshelf-tfstate-my-project-id"
    prefix = "bookshelf/network"
  }
}

The configuration arguments are:

Argument (environment variable) Meaning

bucket

Required. The bucket name, globally unique.

prefix

Optional. A folder inside the bucket. The default workspace is stored at <prefix>/default.tfstate, and a named workspace at <prefix>/<name>.tfstate.

credentials (GOOGLE_BACKEND_CREDENTIALS, GOOGLE_CREDENTIALS)

Local path to credentials in JSON format. Unset means ADC. The identity needs the Storage Object Admin role on the bucket.

impersonate_service_account (GOOGLE_BACKEND_IMPERSONATE_SERVICE_ACCOUNT, GOOGLE_IMPERSONATE_SERVICE_ACCOUNT) and impersonate_service_account_delegates

Service account to impersonate for the state bucket only. The caller needs the Token Creator role on it.

access_token

A temporary OAuth 2.0 token, used instead of credentials when both are set.

kms_encryption_key (GOOGLE_KMS_ENCRYPTION_KEY)

A Cloud KMS key, in the form projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/NAME, used to read and write state (customer-managed encryption key, CMEK).

encryption_key (GOOGLE_ENCRYPTION_KEY)

A 32-byte base64-encoded customer-supplied encryption key (CSEK).

storage_custom_endpoint (GOOGLE_BACKEND_STORAGE_CUSTOM_ENDPOINT, GOOGLE_STORAGE_CUSTOM_ENDPOINT)

A Private Service Connect endpoint for the Cloud Storage API: protocol, DNS name and the path /storage/v1/b.

Several behaviours are worth stating precisely:

  • Locking is implemented with a second object next to the state, <prefix>/<name>.tflock, which the backend creates only if it does not already exist and removes with a generation check when the run ends. A second run therefore fails immediately with a lock error that names the object, instead of overwriting the state. If a run is killed, remove the lock with terraform force-unlock LOCK_ID after making sure nobody is still running. The file name is visible in the backend source rather than in the documentation, so treat it as an implementation detail and rely on -lock-timeout and force-unlock in scripts. See State for locking in general.

  • Credentials in the block are copied into .terraform and into plan files. The documentation warns that values passed with -backend-config or written into the block end up in the .terraform directory and in saved plans, and recommends environment variables for credentials and other sensitive values. Keep the block free of secrets and supply the bucket through a partial configuration if the same code serves several environments, as described in Backends and Remote State.

  • A mistake from older tutorials: a gcs block takes bucket and prefix, never key or region, which are arguments of the S3 backend.

  • Encryption has two flavours with different consequences. For customer-managed keys in Cloud KMS, Terraform can migrate between keys or away from CMEK by itself, because Google keeps the key and the change takes effect on the next write. The project’s Cloud Storage service agent must be allowed to use the key (the Cloud KMS CryptoKey Encrypter/Decrypter role). For customer-supplied keys, Terraform cannot migrate state away from or between keys, because Google does not store them and Terraform loses the old key when the configuration changes: you must first rewrite the object with the old key. In both cases a lost or deleted key makes the state unrecoverable. Reading CMEK-encrypted state through terraform_remote_state needs no key in its config.

  • Private access: storage_custom_endpoint points the backend at a Private Service Connect endpoint, which keeps state traffic off the public path in locked-down networks.

Creating the state bucket

The chicken-and-egg problem has a simple resolution, which Google’s own tutorial uses: create the bucket with a small configuration that has a local backend, add the gcs backend block afterwards, and run terraform init -migrate-state to move the state into the bucket. Many teams keep this bootstrap configuration in its own repository or directory and keep its state in the bucket it created, or accept that it has a local state that is stored with the platform team’s documentation; either choice is fine as long as it is deliberate. The gcloud equivalent is in Infrastructure as Code on Google Cloud.

A state bucket has requirements that ordinary buckets do not: only the pipeline and a few administrators may touch it, object versions must be kept (with a cap, because every apply writes a new version), the bucket must not be deletable by mistake, and public access must be impossible.

terraform {
  required_version = ">= 1.11"

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

variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-central1"
}

resource "google_storage_bucket" "tfstate" {
  project  = var.project_id
  name     = "bookshelf-tfstate-${var.project_id}"
  location = var.region

  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  force_destroy               = false

  versioning {
    enabled = true
  }

  # Keep the ten newest noncurrent versions of each state object.
  lifecycle_rule {
    condition {
      num_newer_versions = 10
      with_state         = "ARCHIVED"
    }
    action {
      type = "Delete"
    }
  }

  soft_delete_policy {
    retention_duration_seconds = 604800
  }

  labels = {
    purpose = "terraform-state"
  }

  lifecycle {
    prevent_destroy = true
  }
}

# Only the deployer service account (and administrators, granted elsewhere) can use the bucket.
resource "google_storage_bucket_iam_member" "tfstate_deployer" {
  bucket = google_storage_bucket.tfstate.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:bookshelf-deployer@${var.project_id}.iam.gserviceaccount.com"
}

output "tfstate_bucket" {
  value = google_storage_bucket.tfstate.name
}

In the provider’s schema uniform_bucket_level_access defaults to false and public_access_prevention to inherited, so both must be set explicitly for a hardened bucket. The soft_delete_policy block is optional and computed: leaving it out keeps whatever the service decides, and a value of 0 for retention_duration_seconds disables soft delete. Cloud Storage’s own documentation explains that versioning and soft delete have different costs; the lifecycle rule above limits the former. The num_newer_versions condition cannot be set to 0 from Terraform, as the provider documents.

The workflow to adopt the bucket afterwards is:

terraform init
terraform apply -var="project_id=my-project-id"

# Add the backend block to the configuration, then move the state:
terraform init -migrate-state
# Answer "yes" when asked to copy the existing state to the new backend.
terraform state list

The -migrate-state flag is the supported way to move state between backends. Older tutorials, including the book that motivates this page, suggest state pull and state push, which the documentation describes as dangerous and unnecessary for this job.

Reading another configuration’s outputs

The terraform_remote_state data source can read a gcs state, as Google’s root-module guidance suggests when a network project exposes values to application projects. It needs read access to the whole state, which includes secrets, so prefer to expose only what is necessary through the producer’s outputs, and consider the alternatives (data sources, or publishing values to a parameter store) discussed in Backends and Remote State.

data "terraform_remote_state" "network" {
  backend = "gcs"

  config = {
    bucket = "bookshelf-tfstate-my-project-id"
    prefix = "bookshelf/network"
  }
}

locals {
  subnet_id = data.terraform_remote_state.network.outputs.app_subnet_id
}

OpenTofu documents the same backend with one more argument, universe_domain (and the environment variables GOOGLE_BACKEND_UNIVERSE_DOMAIN and GOOGLE_CLOUD_UNIVERSE_DOMAIN), used together with a private endpoint for sovereign or partner clouds. OpenTofu can also encrypt state and plan files client side before they reach the bucket, which Terraform cannot do; see State and OpenTofu.

Infrastructure Manager

Infrastructure Manager (Infra Manager) is Google Cloud’s managed Terraform service. You hand it a Terraform configuration, which it calls a blueprint, from a Cloud Storage bucket, a Git repository or a local directory. It creates a deployment, runs Terraform for you inside an ephemeral Cloud Build environment under a service account that you choose, keeps the state, and records every revision with its logs, its configuration, the list of created resources and the state file. Each deployment has the same life cycle you know from the CLI: the service runs terraform init, validate and apply (or destroy when you delete the deployment), and it retries init and apply a limited number of times after transient failures such as network outages. A preview shows the plan without changing anything. The service does not deploy applications onto the resources; Google points to Cloud Build, Cloud Deploy and App Lifecycle Manager for that.

The Google Cloud section covers the first steps, roles and console flow in Infrastructure as Code on Google Cloud. This section concentrates on what changes for someone who already knows Terraform.

Constraints on the configuration

Infra Manager imposes rules that ordinary Terraform does not:

  • There is no backend block. The documentation says backend blocks should not be defined: the service owns the state. The state is available as a file by locking the deployment, downloading it through a signed URL and uploading it again (gcloud infra-manager deployments lock, export-statefile, import-statefile, unlock); the permissions for that are part of roles/config.admin.

  • The entry point must be a plain root module. Templating or generation of Terraform is not supported.

  • No personal or sensitive data in configuration values, and provisioners are discouraged because the runtime environment can change at any time.

  • The Terraform version is chosen from a short list. The page "Terraform and Infrastructure Manager" lists the supported versions with their lifecycle dates. When this page was written (the documentation was last updated on 2026-10-20) the list contained 1.2.3 (deprecated from 2026-01-08, end of support 2026-02-08, auto-migrated to 1.5.7), 1.3.10, 1.4.7 and 1.5.7, and the newest entry was 1.5.7. By default the latest supported version is used, --tf-version-constraint selects one, and an update can raise the version but never lower it. Deprecated versions give a warning, and an obsolete version fails new deployments; an organization policy can opt in to automatic migration (gcloud infra-manager automigrationconfig enable-auto-migration).

  • Creating or updating a deployment times out after two hours, and a preview after one. Large configurations should be split into several deployments.

The version list has direct consequences for how you write the code. Everything introduced after Terraform 1.5 is out of reach: ephemeral resources, write-only arguments (1.10 and 1.11), provider-defined functions, removed blocks (1.7), and terraform test (1.6) do not apply, and a module that declares a required_version of >= 1.12.2, which is what the Cloud Foundation Fabric modules do, cannot be used at all. The Cloud Foundation Toolkit network module, whose requirement is >= 1.3, can. Infra Manager can also use either the HashiCorp-maintained provider (the default) or a service-maintained copy of google and google-beta, selected with --provider-source=SERVICE_MAINTAINED; the supported provider versions per region are listed in a manifest.json in a Google-maintained bucket. Check the compatibility of your provider constraint with those versions before the first deployment.

A configuration that is meant to run in Infra Manager therefore looks like this:

terraform {
  required_version = ">= 1.3"

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

variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-central1"
}

provider "google" {
  project = var.project_id
  region  = var.region
}

resource "google_storage_bucket" "covers" {
  name                        = "bookshelf-covers-${var.project_id}"
  location                    = var.region
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  force_destroy               = false

  versioning {
    enabled = true
  }
}

output "covers_bucket" {
  value = google_storage_bucket.covers.name
}

Deploying with gcloud

Before the first deployment, enable the config.googleapis.com API and create the service account the deployment runs as. Grant it roles/config.agent in the project that runs Infra Manager, plus the roles the resources need (and, for the resources being deleted by an update, the permissions to delete them). People who create deployments need roles/config.admin and roles/iam.serviceAccountUser on that service account. When the service account lives in another project than the deployment, extra grants are needed: the organization policy constraint iam.disableCrossProjectServiceAccountUsage must not be enforced on the service account’s project, and the Infra Manager service agent needs roles/iam.serviceAccountUser there.

PROJECT_ID="my-project-id"
LOCATION="us-central1"
SA="projects/${PROJECT_ID}/serviceAccounts/bookshelf-im@${PROJECT_ID}.iam.gserviceaccount.com"

# Preview first: the plan, without changes
gcloud infra-manager previews create \
  "projects/${PROJECT_ID}/locations/${LOCATION}/previews/bookshelf-covers-preview" \
  --service-account="${SA}" \
  --local-source=./covers \
  --input-values="project_id=${PROJECT_ID}"

# Create or update the deployment
gcloud infra-manager deployments apply \
  "projects/${PROJECT_ID}/locations/${LOCATION}/deployments/bookshelf-covers" \
  --service-account="${SA}" \
  --local-source=./covers \
  --input-values="project_id=${PROJECT_ID}" \
  --quota-validation=ENFORCED

# Inspect the result
gcloud infra-manager deployments describe \
  "projects/${PROJECT_ID}/locations/${LOCATION}/deployments/bookshelf-covers"

Use --gcs-source gs://BUCKET/OBJECT for a zipped configuration in Cloud Storage (versioned buckets let you pin a generation), or --git-source-repo, --git-source-directory and --git-source-ref for Git. A private repository must first be connected to Cloud Build or Developer Connect. --quota-validation can be ENABLED (report) or ENFORCED (fail when quota is insufficient); leaving it out disables the check, and it only covers a limited set of resources.

A deployment is deleted with gcloud infra-manager deployments delete, which destroys the resources; the option --delete-policy=abandon removes the deployment but leaves the resources in place, which is the safe way to hand them to a different tool.

Managing deployments with Terraform itself

The provider has a resource for the deployment, google_config_deployment, so even the Infrastructure Manager layer can be code. Its required arguments are name, location, service_account and a terraform_blueprint block with either a gcs_source or a git_source (repo, directory, ref) and any number of input_values blocks. Optional arguments include tf_version_constraint, artifacts_gcs_bucket, worker_pool, quota_validation, import_existing_resources and force_destroy. The documentation describes the Git source as a public repository.

resource "google_config_deployment" "covers" {
  name            = "bookshelf-covers"
  location        = var.region
  service_account = "projects/${var.project_id}/serviceAccounts/bookshelf-im@${var.project_id}.iam.gserviceaccount.com"

  terraform_blueprint {
    git_source {
      repo      = "https://github.com/example-org/bookshelf-infrastructure"
      directory = "covers"
      ref       = "v1.0.0"
    }

    input_values {
      variable_name = "project_id"
      input_value   = var.project_id
    }
  }

  quota_validation = "ENFORCED"
}

Choosing between Infrastructure Manager and a pipeline you run

Infra Manager removes the need to host a runner, to store state yourself and to hand credentials to a CI system: the deployment runs as a service account inside Google Cloud, previews and revisions are first-class, and audit logs record who changed what. The price is a Terraform version of at most 1.5.7 on the documented list, state that you reach only through the lock, export and import commands, and none of the language and CLI features added since. A pipeline that you operate (see CI/CD and Automation), or HCP Terraform (HCP Terraform and Terraform Enterprise), gives you the current language and CLI, at the cost of running more yourself. Many teams use Infra Manager for a few well-bounded foundations, for example a landing-zone repository, and a normal pipeline for application stacks. Config Connector and Deployment Manager are covered in the Google Cloud section. Deployment Manager support was discontinued on 2026-04-01, the maximum extension of support for migration blockers ends on 2027-03-31, and the service is turned down after 2027-06-30; Infra Manager is the migration target.

Module collections: terraform-google-modules and Cloud Foundation Fabric

Google publishes two open-source collections of Terraform modules, with different philosophies. Both are maintained by Google Cloud engineers, both are Apache 2.0, and neither is a substitute for reading what a module creates before applying it.

terraform-google-modules (Cloud Foundation Toolkit, CFT) Cloud Foundation Fabric

Shape

One repository per module under the terraform-google-modules organization, published on the Terraform Registry as terraform-google-modules/NAME/google, with submodules and examples/ folders.

One repository, tagged as a whole (the latest tag checked is v59.2.0), with a modules folder, an organization-wide landing-zone toolkit called FAST, and examples.

Philosophy

Opinionated, implementing Google Cloud best practices, for gradual adoption and off-the-shelf use.

Lean modules that stay close to the provider resources and support IAM together with creation; meant to be forked and changed, or used as is for prototypes.

Examples

network, cloud-storage, project-factory, sql-db, service-accounts, gcloud, github-actions-runners.

project, folder, gcs, net-vpc, cloud-run-v2, cloud-function-v2, artifact-registry, kms, secret-manager, bigquery-dataset.

Provider and CLI requirements

The network module 18.3.1 needs Terraform >= 1.3 and google and google-beta >= 4.64, < 9.

The gcs module in v59.2.0 needs Terraform >= 1.12.2 and google and google-beta >= 8.6.0, < 9.0.0.

The generic mechanics of consuming a module (versions, source forms, ?ref= for Git, the lock of module selections in .terraform/modules) are in Modules, and writing your own is in Module Development and Publishing. Three things are specific to Google’s collections:

  1. Pin tightly and read the changelog. Both collections release frequently and make breaking changes in major versions. A registry module takes a version constraint; a Fabric module is referenced by a Git source with a tag, or by a path in a fork of the repository, which is the approach the repository itself recommends.

  2. Check the provider range. A module declares a minimum and a maximum provider version. If two modules disagree, terraform init fails; this is the usual reason an upgrade to a new provider major must wait for the modules.

  3. Check the Terraform floor. A module that needs a newer language than your runner offers (for example Fabric modules with Infrastructure Manager) cannot be used.

module "network" {
  source  = "terraform-google-modules/network/google"
  version = "~> 18.3"

  project_id   = var.project_id
  network_name = "bookshelf-${var.environment}"
  routing_mode = "REGIONAL"

  subnets = [
    {
      subnet_name           = "app"
      subnet_ip             = "10.10.0.0/24"
      subnet_region         = var.region
      subnet_private_access = "true"
      subnet_flow_logs      = "true"
    },
  ]
}

module "covers_bucket" {
  source = "github.com/GoogleCloudPlatform/cloud-foundation-fabric//modules/gcs?ref=v59.2.0"

  project_id = var.project_id
  prefix     = "bookshelf"
  name       = "covers"
  location   = var.region
  versioning = true
}

The network module call follows the usage shown in the module’s README (project_id, network_name, routing_mode and a subnets list whose objects carry subnet_name, subnet_ip, subnet_region, subnet_private_access and subnet_flow_logs). The Fabric call is based on the module’s "Simple bucket" example, which in the repository itself uses the local path ./fabric/modules/gcs; the Git source with ?ref= is standard Terraform module-source syntax applied to the repository’s tag, and the //modules/gcs part selects the subdirectory. Some modules also ship a project_services submodule, which enables APIs and exposes an enable_apis flag and disable_services_on_destroy; that is the form Google’s module guidance prefers over writing google_project_service by hand in a reusable module.

Module collections interact with the rest of this section: a Fabric or CFT module is a normal child module, so Testing, Code Quality and Security Scanning and Policy as Code apply to them as to your own, and Google’s blueprint testing framework is mentioned in its testing guidance. For a landing zone beyond one project (organization policies, folders, shared VPC, logging), look at Fabric FAST and the Enterprise foundations blueprint described in Infrastructure as Code on Google Cloud rather than assembling it resource by resource.

Google’s Terraform best practices

Google documents its recommendations for Terraform in a set of pages: general style and structure, reusable modules, root modules, dependency management, cross-configuration communication, working with Google Cloud resources, version control, operations, security and testing. The ones that bear on this page are collected below, with how the Bookshelf applies them. Where Google’s advice differs from HashiCorp’s, both are given.

Recommendation Detail In the Bookshelf

Keep root modules small

Do not put more than 100 resources (ideally a few dozen) in one state, because everything in a root configuration is refreshed on every run. Use a separate directory for each application.

One directory per layer and service (see the layout below), each with its own prefix.

Environments are directories, not workspaces

Split a service into a modules directory and an environments directory with a root configuration per environment (dev, qa, prod), each with backend.tf and main.tf. Use only the default workspace: with CLI workspaces it is hard to inspect each one, a shared backend becomes a single point of failure, and terraform.workspace conditionals make code harder to read.

Environment directories calling shared modules. The trade-offs are discussed in Workspaces, Environments and Project Structure.

Pin providers to a minor version and check in the lock file

Name the file versions.tf; commit .terraform.lock.hcl.

~> 8.8 with a lock file; see the earlier caveat.

Variables in terraform.tfvars

For root modules, use the default variables file and avoid -var-file and -var on the command line, which are ephemeral and easy to forget.

Environment values in terraform.tfvars per directory; secrets only through TF_VAR_ variables, which Google’s page does not cover.

Standard module structure

main.tf, variables.tf, outputs.tf, an examples folder, underscores in names, a single resource of a type named main, descriptions for every variable and output, units in numeric names, built-in formatting.

Followed by every example on this page.

Expose outputs; use remote state to share them

Root modules should expose useful outputs, and other configurations read them with terraform_remote_state.

app_subnet_id, db_connection_name, and so on.

Activate APIs in modules carefully

A module that enables APIs must let callers turn that off (enable_apis, default true) and must not disable services on destroy.

google_project_service with disable_on_destroy set to false.

Protect stateful resources

Enable deletion protection, and use lifecycle { prevent_destroy = true } for databases and state.

deletion_protection and deletion_protection_enabled on Cloud SQL, the same on the state bucket.

Limit custom scripts and complex expressions

Prefer data sources and provider resources to provisioners; keep conditions simple; use count or for_each for optional resources.

No provisioners on this page.

Tag releases of shared modules and publish them to a registry

Modules need an owners file, outputs for every resource, minimum provider versions, a labels variable and inline submodules treated as private.

See Module Development and Publishing.

Security: remote state, encryption, no secrets in state, separation of duties, pre-apply checks

Use the Cloud Storage backend with restricted access and ignore state files in Git; encrypt with CSEK or CMEK; avoid tls_private_key, google_service_account_key, google_client_config and similar; mark outputs sensitive; give each layer its own service account; check plans with gcloud beta terraform vet and audit with Security Health Analytics, InSpec or Serverspec after apply.

Sections below; see also Policy as Code.

Testing from cheap to expensive

Static analysis with terraform validate; module integration tests with the blueprint testing framework, Terratest, Kitchen-Terraform, InSpec or tftest; end-to-end tests in a separate, isolated project with randomized project IDs and names.

See Testing.

Google’s pages are not always current in their examples: the root-module page, for instance, shows a provider constraint of ~> 4.0.0, so read them for the principle and take versions from the registry. They also pre-date several Terraform features. Where a newer feature does the same job better, such as ephemeral values and write-only arguments for secrets, prefer it.

A layout for the Bookshelf

Following the guidance, split the Bookshelf into layers that change at different speeds and that have different owners. Each directory is a root module with its own state object in the same bucket.

bookshelf-infrastructure/
  bootstrap/               # state bucket, deployer service account, WIF (local state, run rarely)
  modules/
    network/               # VPC, subnets, firewall, private services access
    data/                  # Cloud SQL, BigQuery, Secret Manager secrets, KMS
    apps/                  # Artifact Registry, Cloud Run, Cloud Run functions, Pub/Sub
    observability/         # sinks, alert policies
  environments/
    dev/
      backend.tf           # bucket + prefix "bookshelf/dev"
      main.tf              # calls the modules
      terraform.tfvars
      versions.tf
    prod/
      ...

One directory per environment with several layers inside it is the compromise most teams settle on: the network and data layers are separate states with separate prefixes (for example bookshelf/prod/network), so a mistake in an application change cannot touch the database, and they share modules with the other environments. How to wire several states together, and when Terraform Stacks or HCP Terraform are better, is covered in Workspaces, Environments and Project Structure.

flowchart TB boot["bootstrap: state bucket, deployer service account, WIF"] subgraph env["environments/prod (one project)"] net["network layer: VPC, subnet, firewall, private services access"] data["data layer: Cloud SQL, BigQuery, Secret Manager, KMS"] apps["apps layer: Artifact Registry, Cloud Run, Cloud Run function, Pub/Sub"] obs["observability layer: sinks and alerts"] end boot --> net net --> data net --> apps data --> apps apps --> obs data --> obs

The diagram shows the order in which layers are applied (and, reversed, destroyed). The bootstrap layer creates the state bucket and the identities, then each layer reads what it needs from the previous ones through remote state outputs.

The Bookshelf on Google Cloud, rebuilt

The next two major sections rebuild, with current resources, the Google Cloud material that introductory Terraform books tend to cover in their chapters on provisioning and on secrets and resilience. The book this section draws on (Wang, Terraform Made Easy, Apress, 2024) targets the 5.38.0 provider and Terraform 1.4 to 1.8, so several of its resources have since been retired. The rebuild keeps its topics, in the Bookshelf’s vocabulary, and replaces the code rather than updating it. The differences are listed in the section on where the book is outdated.

Each subsection is a part of one layered configuration. They share the variables below, which every layer declares, and they use names such as google_compute_network.bookshelf across files only to keep the listings short; in a real repository the layers are separate roots that exchange values through outputs and terraform_remote_state.

variable "project_id" {
  type = string
}

variable "region" {
  type    = string
  default = "us-central1"
}

variable "environment" {
  type    = string
  default = "dev"
}

Buckets, saved plans and conditional creation

The first resource most people create on Google Cloud is a Cloud Storage bucket, and it is the right place to learn the workflow. The covers bucket holds book cover images, so it is private, versioned and labelled.

resource "google_storage_bucket" "covers" {
  project  = var.project_id
  name     = "bookshelf-covers-${var.environment}-${var.project_id}"
  location = var.region

  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  force_destroy               = var.environment != "prod"

  versioning {
    enabled = true
  }

  lifecycle_rule {
    condition {
      age            = 90
      matches_prefix = ["uploads/"]
    }
    action {
      type = "Delete"
    }
  }

  labels = {
    component = "covers"
  }
}

output "covers_bucket" {
  value = google_storage_bucket.covers.name
}

Bucket names are global across all of Google Cloud, which is why the name embeds the project ID, and they cannot be reused for a while after deletion. force_destroy defaults to false, in which case destroying a bucket that still contains objects fails; the expression above relaxes it everywhere except production. The uploads/ prefix rule only makes sense if the application writes there, so replace it with your own layout.

The workflow around the resource is the standard one, with a saved plan so that what was reviewed is what is applied:

terraform fmt -check
terraform validate
terraform plan -var="project_id=my-project-id" -var="environment=dev" -out=tfplan
terraform show tfplan
terraform apply tfplan

Introductory material often creates several resources with count and passes values with -var. -var on the command line is fine for a demonstration, but Google’s guidance prefers terraform.tfvars for root modules because command-line values are easy to forget. count identifies instances by position, so removing the first element shifts every address and plans a destroy-and-create for the others; for_each over a map or set keys them by name. Both are described in count, for_each and depends_on.

The network: VPC, subnets and firewall rules

A custom-mode VPC with explicit subnets is the only sensible starting point: auto_create_subnetworks creates a subnet in every region from 10.128.0.0/9 when set to true, which collides with anything you later connect. The example drives subnets and firewall rules from locals, uses a dynamic block for the repeated allow blocks, and exposes the results with a for expression.

locals {
  subnets = {
    app = {
      cidr = "10.10.0.0/24"
    }
    data = {
      cidr = "10.10.1.0/24"
    }
  }

  firewall_rules = {
    allow-iap-ssh = {
      source_ranges = ["35.235.240.0/20"] # Identity-Aware Proxy TCP forwarding
      target_tags   = ["web"]
      protocols     = { tcp = ["22"] }
    }
    allow-health-checks = {
      source_ranges = ["35.191.0.0/16"] # Google Cloud health checks
      target_tags   = ["web"]
      protocols     = { tcp = ["80", "443"] }
    }
  }
}

resource "google_compute_network" "bookshelf" {
  project                 = var.project_id
  name                    = "bookshelf-${var.environment}"
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "bookshelf" {
  for_each = local.subnets

  project                  = var.project_id
  name                     = "bookshelf-${var.environment}-${each.key}"
  region                   = var.region
  network                  = google_compute_network.bookshelf.id
  ip_cidr_range            = each.value.cidr
  private_ip_google_access = true

  log_config {
    aggregation_interval = "INTERVAL_10_MIN"
    flow_sampling        = 0.5
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

resource "google_compute_firewall" "rules" {
  for_each = local.firewall_rules

  project       = var.project_id
  name          = "bookshelf-${var.environment}-${each.key}"
  network       = google_compute_network.bookshelf.name
  direction     = "INGRESS"
  priority      = 1000
  source_ranges = each.value.source_ranges
  target_tags   = each.value.target_tags

  dynamic "allow" {
    for_each = each.value.protocols
    content {
      protocol = allow.key
      ports    = allow.value
    }
  }

  log_config {
    metadata = "INCLUDE_ALL_METADATA"
  }
}

output "subnet_ids" {
  value = { for name, subnet in google_compute_subnetwork.bookshelf : name => subnet.id }
}

Google Cloud firewall rules are stateful, apply to a whole VPC network, and are matched by target tag or target service account, never by subnet. Every VPC has two implied rules, allow all egress and deny all ingress, which is why the configuration only lists exceptions. Tags are free text and anyone who can edit an instance can add one, so for sensitive workloads use target_service_accounts instead of target_tags. If your organization uses network firewall policies, google_compute_network_firewall_policy and its rule resources are the modern alternative; the network_firewall_policy_enforcement_order argument of the network decides how policies and classic rules interleave. The security model is in VPC networking. Subnet flow logs and firewall logs cost money in proportion to traffic, so use the sampling and aggregation arguments deliberately.

Cloud SQL private IP and Cloud Run’s Direct VPC egress both use this network. For Cloud SQL, a private services access connection reserves an internal range and peers the VPC with Google’s service-producer network:

resource "google_compute_global_address" "private_services" {
  project       = var.project_id
  name          = "bookshelf-${var.environment}-private-services"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 20
  network       = google_compute_network.bookshelf.id
}

resource "google_service_networking_connection" "private_services" {
  network                 = google_compute_network.bookshelf.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_services.name]
}

The Cloud SQL resource page stresses that the instance does not interpolate anything from the connection, so the instance must declare an explicit depends_on for it. The connection also has a deletion_policy; see the clean-up section for why it matters.

Compute Engine: one VM, many VMs, many zones

Wang’s chapter builds a single instance, then several through a module, then a multi-zone fleet driven by a map in terraform.tfvars. The same progression is shown here in one resource, which is what a module would wrap. The variable is a map(object(...)) with optional attributes and defaults, so that adding a VM is a data change.

variable "web_instances" {
  type = map(object({
    zone         = string
    machine_type = optional(string, "e2-small")
    disk_size_gb = optional(number, 20)
  }))
}

resource "google_service_account" "web" {
  project      = var.project_id
  account_id   = "bookshelf-web-${var.environment}"
  display_name = "Bookshelf web VMs"
}

resource "google_compute_instance" "web" {
  for_each = var.web_instances

  project      = var.project_id
  name         = "bookshelf-web-${each.key}"
  zone         = each.value.zone
  machine_type = each.value.machine_type
  tags         = ["web"]

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-13"
      size  = each.value.disk_size_gb
      type  = "pd-balanced"
    }
  }

  network_interface {
    subnetwork = google_compute_subnetwork.bookshelf["app"].id
    # No access_config block: the VM has no external IP address.
  }

  service_account {
    email  = google_service_account.web.email
    scopes = ["cloud-platform"]
  }

  shielded_instance_config {
    enable_secure_boot          = true
    enable_vtpm                 = true
    enable_integrity_monitoring = true
  }

  metadata = {
    enable-oslogin = "TRUE"
  }

  allow_stopping_for_update = true
}

output "web_internal_ips" {
  value = { for name, vm in google_compute_instance.web : name => vm.network_interface[0].network_ip }
}
# terraform.tfvars
web_instances = {
  a = { zone = "us-central1-a" }
  b = { zone = "us-central1-b", machine_type = "e2-medium" }
  c = { zone = "us-central1-c", disk_size_gb = 40 }
}

The debian-cloud/debian-13 value is an image family: Google’s operating system details page lists debian-13 as a generally available image family, while Debian 10 and 11 are marked end of service, so snippets that use debian-10 or debian-11 should be replaced. The file name matters: terraform.tfvars, terraform.tfvars.json and files ending in .auto.tfvars are loaded automatically; a file called tf.tfvars is not, and needs -var-file. Never attach the default Compute Engine service account (it is highly privileged by default) and never leave VMs with external IPs unless something requires it; reach them through IAP, as the firewall rule above allows. For a fleet that must scale or heal itself, a managed instance group with an instance template is the proper building block; google_compute_instance is for pets and small fixed fleets. See Compute Engine.

Artifact Registry and the images

Container images live in Artifact Registry, because Container Registry (gcr.io) was shut down on 2025-03-18 according to the Google Cloud section of this site. The baseline in Infrastructure as Code on Google Cloud creates the repository; the version below adds cleanup policies, which delete untagged images and keep recent versions so that the repository does not grow without bound. If you applied the baseline first, import its repository into this layer instead of creating a second one, as described in Import and Query.

resource "google_artifact_registry_repository" "bookshelf" {
  project       = var.project_id
  location      = var.region
  repository_id = "bookshelf"
  format        = "DOCKER"
  description   = "Bookshelf container images"

  # Report what the policies would delete before they delete anything.
  cleanup_policy_dry_run = true

  cleanup_policies {
    id     = "delete-untagged"
    action = "DELETE"
    condition {
      tag_state  = "UNTAGGED"
      older_than = "7d"
    }
  }

  cleanup_policies {
    id     = "keep-recent"
    action = "KEEP"
    most_recent_versions {
      keep_count = 10
    }
  }
}

With cleanup_policy_dry_run set to true the policies are evaluated and reported but nothing is deleted; turn it off after reading the audit logs. In 7.0.0 the defaults of the public_repository fields of remote repositories were removed, which matters only if you proxy upstream registries.

Cloud SQL for PostgreSQL

The database is the part of the Bookshelf that must never be destroyed by accident, so it receives every protective argument the resource has. The example uses PostgreSQL 18, the default major in Cloud SQL according to the database version policy page checked for this page, regional high availability, point-in-time recovery, private IP only, and TLS required.

resource "google_sql_database_instance" "main" {
  project          = var.project_id
  name             = "bookshelf-${var.environment}-pg"
  region           = var.region
  database_version = "POSTGRES_18"

  # Terraform-level protection: apply and destroy fail while this is true.
  deletion_protection = true

  depends_on = [google_service_networking_connection.private_services]

  settings {
    tier              = "db-custom-2-8192"
    edition           = "ENTERPRISE"
    availability_type = "REGIONAL"
    disk_autoresize   = true

    # API-level protection: also blocks gcloud, the console and the API.
    deletion_protection_enabled = true

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.bookshelf.id
      ssl_mode        = "ENCRYPTED_ONLY"
    }

    backup_configuration {
      enabled                        = true
      start_time                     = "03:00"
      point_in_time_recovery_enabled = true
      transaction_log_retention_days = 7

      backup_retention_settings {
        retained_backups = 14
        retention_unit   = "COUNT"
      }
    }

    insights_config {
      query_insights_enabled = true
    }

    user_labels = {
      component = "database"
    }
  }
}

resource "google_sql_database" "catalog" {
  project  = var.project_id
  instance = google_sql_database_instance.main.name
  name     = "catalog"
}

output "db_connection_name" {
  value = google_sql_database_instance.main.connection_name
}

output "db_private_ip" {
  value = google_sql_database_instance.main.private_ip_address
}

Several details of this resource explain most of the surprises people meet:

  • edition has a version-dependent default. If it is omitted, instances with POSTGRES_16 or later default to ENTERPRISE_PLUS, which supports only predefined db-perf-optimized-N-* machine types. A shared-core or custom tier such as db-f1-micro, db-g1-small or db-custom-* then fails at creation with an invalid tier error. Set edition explicitly, as above, or pick a performance-optimized tier.

  • Two protections exist and are different. deletion_protection on the resource (it defaults to protecting when unset) only stops Terraform; settings.deletion_protection_enabled is enforced by the API on every surface. Google’s best practices additionally suggest lifecycle { prevent_destroy = true } for databases and the state bucket, which makes the plan itself fail.

  • Changing database_version upgrades the engine in place, and maintenance_version controls the minor software version. Plan major version upgrades as projects of their own, and read the argument include_replicas_for_major_version_upgrade.

  • MySQL 5.7 is not a good choice. The provider’s list of database_version values still includes MYSQL_5_7, but Google’s policy page lists 5.7 with extended support (paid) since 2025-02-01 and deprecation on 2028-02-01. All instances running a community end-of-life major version are enrolled in extended support and billed for it. Use MYSQL_8_4 or later for MySQL.

  • settings.ip_configuration.require_ssl no longer exists. It was replaced by ssl_mode, with the values ALLOW_UNENCRYPTED_AND_ENCRYPTED, ENCRYPTED_ONLY and TRUSTED_CLIENT_CERTIFICATE_REQUIRED (not available for SQL Server); the removal was made in provider 6.0.1.

  • Private IP is not enough for the instance to be reachable: Cloud Run needs Direct VPC egress or a connector, and engineers need the Cloud SQL Auth Proxy or IAP. ipv4_enabled must be true or a private_network configured; here only the latter is set.

The google_sql_ssl_cert resource still exists for client certificates, but the provider documents that all its arguments, including the private key, are stored in plain text in state. Prefer ssl_mode = ENCRYPTED_ONLY together with the Cloud SQL Auth Proxy or the language connectors, which verify the server through IAM and do not need client certificates; see Cloud SQL.

A read replica is another instance that names the primary in master_instance_name; the provider documents that replication requires existing backups (and binary logging for MySQL) on the primary. Promoting a replica to a standalone instance is done by setting instance_type to CLOUD_SQL_INSTANCE and removing the master reference.

resource "google_sql_database_instance" "replica" {
  project              = var.project_id
  name                 = "bookshelf-${var.environment}-pg-replica"
  region               = var.region
  database_version     = "POSTGRES_18"
  master_instance_name = google_sql_database_instance.main.name
  deletion_protection  = true

  settings {
    tier              = "db-custom-2-8192"
    edition           = "ENTERPRISE"
    availability_type = "ZONAL"

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.bookshelf.id
      ssl_mode        = "ENCRYPTED_ONLY"
    }
  }
}

The database user and its password

A google_sql_user with a literal password argument writes the password into state, which is exactly the anti-pattern the next major section describes. Since Terraform 1.11 the resource accepts a write-only password, password_wo, which is sent to the API but never stored, together with password_wo_version, which you increment to rotate. Provider 7.0.0 made the two arguments mandatory as a pair. The value itself can come from an ephemeral read of Secret Manager, so that it never reaches state or the plan either:

resource "google_secret_manager_secret" "db_password" {
  project   = var.project_id
  secret_id = "bookshelf-${var.environment}-db-password"

  replication {
    auto {}
  }
}

# The first version is added outside Terraform, for example:
#   printf '%s' "$NEW_PASSWORD" | gcloud secrets versions add bookshelf-dev-db-password --data-file=-
# An ephemeral resource is opened during plan, so the version must exist before the user is planned.

ephemeral "google_secret_manager_secret_version" "db_password" {
  project = var.project_id
  secret  = google_secret_manager_secret.db_password.secret_id
  version = "latest"
}

resource "google_sql_user" "catalog_api" {
  project             = var.project_id
  instance            = google_sql_database_instance.main.name
  name                = "catalog_api"
  password_wo         = ephemeral.google_secret_manager_secret_version.db_password.secret_data
  password_wo_version = 1
}

Ephemeral resources are explained in Sensitive Data, Ephemeral Resources and Write-only Arguments. Because the ephemeral read happens at plan time, the very first apply of a fresh environment needs two steps: create the secret container (target the google_secret_manager_secret resource, or apply a separate layer), add a version with gcloud, then apply the rest. The secret then lives in Secret Manager, its version history is controlled by IAM, and rotation is a new version plus an increment of password_wo_version.

Cloud Run v2 for catalog-api

google_cloud_run_service, the Knative-style resource that older material uses, has been superseded by google_cloud_run_v2_service, which maps onto the Cloud Run Admin API v2. The v2 service configures revision settings under template and service-level settings (ingress, traffic, scaling) at the top. Since provider 6.0.0 it has deletion_protection, which defaults to true; the baseline page sets it to false for a throwaway sample, and a real production service should leave it on and flip it only to destroy.

variable "catalog_api_tag" {
  type    = string
  default = "latest"
}

resource "google_service_account" "catalog_api" {
  project      = var.project_id
  account_id   = "catalog-api-${var.environment}"
  display_name = "Bookshelf catalog-api runtime"
}

resource "google_artifact_registry_repository_iam_member" "catalog_api_reader" {
  project    = var.project_id
  location   = var.region
  repository = google_artifact_registry_repository.bookshelf.name
  role       = "roles/artifactregistry.reader"
  member     = google_service_account.catalog_api.member
}

resource "google_secret_manager_secret_iam_member" "catalog_api_db_password" {
  project   = var.project_id
  secret_id = google_secret_manager_secret.db_password.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = google_service_account.catalog_api.member
}

resource "google_cloud_run_v2_service" "catalog_api" {
  project  = var.project_id
  name     = "catalog-api"
  location = var.region
  ingress  = "INGRESS_TRAFFIC_ALL"

  deletion_protection = true

  template {
    service_account                  = google_service_account.catalog_api.email
    max_instance_request_concurrency = 80

    scaling {
      min_instance_count = 0
      max_instance_count = 5
    }

    vpc_access {
      egress = "PRIVATE_RANGES_ONLY"
      network_interfaces {
        network    = google_compute_network.bookshelf.id
        subnetwork = google_compute_subnetwork.bookshelf["app"].id
        tags       = ["catalog-api"]
      }
    }

    containers {
      image = "${var.region}-docker.pkg.dev/${var.project_id}/bookshelf/catalog-api:${var.catalog_api_tag}"

      ports {
        container_port = 8080
      }

      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
        cpu_idle          = true
        startup_cpu_boost = true
      }

      env {
        name  = "DB_HOST"
        value = google_sql_database_instance.main.private_ip_address
      }

      env {
        name  = "DB_NAME"
        value = google_sql_database.catalog.name
      }

      env {
        name = "DB_PASSWORD"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.db_password.secret_id
            version = "latest"
          }
        }
      }
    }
  }

  depends_on = [
    google_artifact_registry_repository_iam_member.catalog_api_reader,
    google_secret_manager_secret_iam_member.catalog_api_db_password,
  ]
}

# Only the web service account may call catalog-api; nothing is public.
resource "google_cloud_run_v2_service_iam_member" "web_invokes_catalog" {
  project  = var.project_id
  location = var.region
  name     = google_cloud_run_v2_service.catalog_api.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:web-${var.environment}@${var.project_id}.iam.gserviceaccount.com"
}

Points worth knowing about this resource:

  • containers.env is a set since 6.0.0, so values are addressed by name and no longer by index, and several env blocks with different names never reorder. Literal values do not support variable references; secrets come from value_source.secret_key_ref, which takes the secret name and a version (latest or a number), and the runtime service account needs roles/secretmanager.secretAccessor on it, which is why the depends_on above is there.

  • Image tags are the application’s deployment mechanism. A Terraform run that changes catalog_api_tag creates a new revision. If CI deploys images with gcloud run deploy, Terraform will want to revert them; either let Terraform own the tag (as above) or add lifecycle { ignore_changes = [template[0].containers[0].image] } and let the pipeline own it. Never apply latest to production.

  • Direct VPC egress (the network_interfaces block, a single interface) needs no connector resource. With egress set to PRIVATE_RANGES_ONLY only traffic to private ranges enters the VPC. The older Serverless VPC Access connector is still available through connector but costs instances.

  • Ingress and invokers are separate controls: ingress limits where requests may come from, while roles/run.invoker decides who may call. Granting it to allUsers makes a service public; do that only for a service that must be public. The provider also has invoker_iam_disabled for services that need no IAM check.

  • Scaling has a service-level scaling block (not used here) and a revision-level template.scaling. See Cloud Run scaling, traffic and jobs for what each setting means.

Cloud Run functions and the event-driven covers pipeline

Wang’s pipeline chains Cloud Storage, Pub/Sub, a Cloud Function, Cloud Run and BigQuery. The Bookshelf version has the same shape and current parts: an uploaded cover (object finalized in the covers bucket) triggers a function that generates thumbnails and publishes a JSON event to a Pub/Sub topic; a BigQuery subscription writes the events straight into a table without any code in between; catalog-api (above) serves the results.

flowchart LR upload["Cover uploaded"] --> covers["Covers bucket"] covers -->|"object finalized"| eventarc["Eventarc trigger"] eventarc --> fn["Cloud Run function: cover-processor"] fn -->|"thumbnail"| covers fn -->|"JSON event"| topic["Pub/Sub topic: cover-events"] topic -->|"BigQuery subscription"| bq["BigQuery table: cover_events"] catalog["Cloud Run: catalog-api"] --> covers catalog --> sql["Cloud SQL PostgreSQL"]

Cloud Functions 2nd generation was renamed Cloud Run functions in August 2024 (release note of 2024-08-21), but the provider resource is still google_cloudfunctions2_function, and the legacy google_cloudfunctions_function is the 1st generation product. The 2nd generation resource is built on Cloud Run and Eventarc: it has build_config (runtime, entry point, source), service_config (memory, CPU, concurrency, scaling, VPC settings, secrets, ingress, service account) and an optional event_trigger. From provider 7.0.0 event_trigger.event_type is required, and service_config.service is output only. Runtimes are listed on the Cloud Run functions runtime support page (it showed python313 and nodejs22 as supported while older Node.js runtimes such as nodejs20 are past their support dates), so check it before choosing one.

data "archive_file" "cover_processor" {
  type        = "zip"
  source_dir  = "${path.module}/functions/cover-processor"
  output_path = "${path.module}/build/cover-processor.zip"
}

resource "google_storage_bucket" "function_source" {
  project                     = var.project_id
  name                        = "bookshelf-fn-source-${var.environment}-${var.project_id}"
  location                    = var.region
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
}

resource "google_storage_bucket_object" "cover_processor" {
  name   = "cover-processor-${data.archive_file.cover_processor.output_md5}.zip"
  bucket = google_storage_bucket.function_source.name
  source = data.archive_file.cover_processor.output_path
}

resource "google_service_account" "cover_processor" {
  project      = var.project_id
  account_id   = "cover-processor-${var.environment}"
  display_name = "Bookshelf cover-processor"
}

# The Cloud Storage service agent must be able to publish to Pub/Sub for the trigger to work.
data "google_storage_project_service_account" "gcs" {
  project = var.project_id
}

resource "google_project_iam_member" "gcs_publisher" {
  project = var.project_id
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${data.google_storage_project_service_account.gcs.email_address}"
}

# Roles the function's service account needs to be invoked by Eventarc and to pull its image.
resource "google_project_iam_member" "cover_processor" {
  for_each = toset([
    "roles/run.invoker",
    "roles/eventarc.eventReceiver",
    "roles/artifactregistry.reader",
  ])

  project = var.project_id
  role    = each.key
  member  = google_service_account.cover_processor.member
}

resource "google_pubsub_topic" "cover_events" {
  project                    = var.project_id
  name                       = "bookshelf-${var.environment}-cover-events"
  message_retention_duration = "86400s"
}

resource "google_pubsub_topic_iam_member" "cover_processor_publisher" {
  project = var.project_id
  topic   = google_pubsub_topic.cover_events.name
  role    = "roles/pubsub.publisher"
  member  = google_service_account.cover_processor.member
}

resource "google_cloudfunctions2_function" "cover_processor" {
  project  = var.project_id
  name     = "cover-processor"
  location = var.region

  build_config {
    runtime     = "python313"
    entry_point = "process_cover"

    source {
      storage_source {
        bucket = google_storage_bucket.function_source.name
        object = google_storage_bucket_object.cover_processor.name
      }
    }
  }

  service_config {
    min_instance_count             = 0
    max_instance_count             = 5
    available_memory               = "512Mi"
    timeout_seconds                = 120
    ingress_settings               = "ALLOW_INTERNAL_ONLY"
    all_traffic_on_latest_revision = true
    service_account_email          = google_service_account.cover_processor.email

    environment_variables = {
      EVENTS_TOPIC = google_pubsub_topic.cover_events.id
    }
  }

  event_trigger {
    trigger_region        = var.region
    event_type            = "google.cloud.storage.object.v1.finalized"
    retry_policy          = "RETRY_POLICY_RETRY"
    service_account_email = google_service_account.cover_processor.email

    event_filters {
      attribute = "bucket"
      value     = google_storage_bucket.covers.name
    }
  }

  depends_on = [
    google_project_iam_member.gcs_publisher,
    google_project_iam_member.cover_processor,
  ]
}

The event, the IAM roles and the ordering follow the provider’s own Cloud Storage example. Some more points:

  • The trigger location must match the bucket location: the provider’s example notes that the trigger must be in the same location as the bucket, which is why trigger_region and the covers bucket both use var.region.

  • The Cloud Storage service agent is created on demand; use the google_storage_project_service_account data source to obtain its address instead of constructing it.

  • The build runs as a service account of its own, which build_config.service_account can set; give it a dedicated account with only the permissions to build and write images, rather than relying on defaults.

  • Zip names that include the content hash (as above) make the function redeploy whenever the code changes, which a fixed object name would not.

  • The archive provider (hashicorp/archive) is needed for the data source; add it to required_providers.

The BigQuery half has more to say about identity than about code. A BigQuery subscription is a Pub/Sub subscription with a bigquery_config block: Pub/Sub writes each message into the table without a consumer, either to a single data column, or, with use_table_schema, into the columns of the table, in which case messages must be JSON. Which identity writes is selected with service_account_email; the documented default is the Pub/Sub service agent, but a dedicated account is easier to audit and to grant narrowly.

resource "google_bigquery_dataset" "analytics" {
  project                    = var.project_id
  dataset_id                 = "bookshelf_analytics"
  location                   = var.region
  delete_contents_on_destroy = false

  labels = {
    component = "analytics"
  }
}

resource "google_bigquery_table" "cover_events" {
  project             = var.project_id
  dataset_id          = google_bigquery_dataset.analytics.dataset_id
  table_id            = "cover_events"
  deletion_protection = true

  time_partitioning {
    type  = "DAY"
    field = "processed_at"
  }

  schema = jsonencode([
    { name = "book_id", type = "STRING", mode = "REQUIRED" },
    { name = "object_name", type = "STRING", mode = "REQUIRED" },
    { name = "width", type = "INT64", mode = "NULLABLE" },
    { name = "height", type = "INT64", mode = "NULLABLE" },
    { name = "processed_at", type = "TIMESTAMP", mode = "REQUIRED" },
  ])
}

resource "google_service_account" "pubsub_bigquery" {
  project      = var.project_id
  account_id   = "pubsub-bq-${var.environment}"
  display_name = "Pub/Sub to BigQuery writer"
}

resource "google_project_iam_member" "pubsub_bigquery" {
  for_each = toset(["roles/bigquery.metadataViewer", "roles/bigquery.dataEditor"])

  project = var.project_id
  role    = each.key
  member  = google_service_account.pubsub_bigquery.member
}

resource "google_pubsub_subscription" "cover_events_bigquery" {
  project = var.project_id
  name    = "cover-events-to-bigquery"
  topic   = google_pubsub_topic.cover_events.id

  bigquery_config {
    table                 = "${google_bigquery_table.cover_events.project}.${google_bigquery_table.cover_events.dataset_id}.${google_bigquery_table.cover_events.table_id}"
    use_table_schema      = true
    drop_unknown_fields   = true
    service_account_email = google_service_account.pubsub_bigquery.email
  }

  depends_on = [google_project_iam_member.pubsub_bigquery]
}

deletion_protection on google_bigquery_table is enabled unless it is set, so destroying a table needs two steps: set it to false, apply, then destroy. drop_unknown_fields makes Pub/Sub discard message fields that are not columns of the table; without it, messages with extra fields stay in the backlog. A topic, subscription or table that changes shape should be treated as a migration, not as an in-place edit.

Service accounts and keys

Wang creates service accounts and downloads JSON keys. The right default today is: create service accounts with google_service_account, grant roles with the non-authoritative google_project_iam_member, and never create keys. A google_service_account_key resource stores the private key in state and produces a credential that does not expire; every use case that seems to need one has a keyless alternative:

Need Keyless alternative

Terraform on a laptop

ADC with impersonation of the deployer account.

Terraform in CI

Workload Identity Federation, see above.

Code running on Google Cloud

The service account attached to Cloud Run, the VM or the function.

Code running elsewhere

Workload Identity Federation with the platform’s identity provider.

A short-lived token for a script

The google_service_account_access_token or google_service_account_id_token ephemeral resource, or gcloud auth print-access-token --impersonate-service-account.

Granting the Owner, Editor or Viewer basic roles to a service account is explicitly discouraged in production by Google’s authentication documentation; use predefined or custom roles scoped to what the account does. The IAM resources are covered in the next major section.

GKE

Wang provisions a GKE cluster with node pools, add-ons, network policy and the Kubernetes provider to create a Service, an Ingress and RBAC objects. The Kubernetes half moves to Docker, Kubernetes and Helm Providers, including the two-phase apply problem when a provider is configured from a cluster that does not exist yet, and the guidance to hand application delivery to Helm or GitOps (see the Kubernetes section). What belongs here is the cluster itself:

resource "google_container_cluster" "bookshelf" {
  project  = var.project_id
  name     = "bookshelf-${var.environment}"
  location = var.region

  enable_autopilot    = true
  deletion_protection = true

  network    = google_compute_network.bookshelf.id
  subnetwork = google_compute_subnetwork.bookshelf["app"].id

  release_channel {
    channel = "REGULAR"
  }

  private_cluster_config {
    enable_private_nodes = true
  }

  master_authorized_networks_config {
    cidr_blocks {
      cidr_block   = "203.0.113.0/24"
      display_name = "office-vpn"
    }
  }

  ip_allocation_policy {}
}

An Autopilot cluster has no node pools to manage and applies hardened defaults; use Standard clusters only when you need node-level control, and then create node pools as separate google_container_node_pool resources with remove_default_node_pool. The range 203.0.113.0/24 is a documentation address; the point is that authorized networks should list real administrator ranges, never 0.0.0.0/0, which defeats the control. deletion_protection is a google_container_cluster argument too. The provider’s own guide on GKE (using_gke_with_terraform) and the section GKE clusters cover node pools, upgrades and networking in depth.

Secrets, security and resilience

The second half of the rebuild is about what must not leak and what must survive. It follows the same layers: secrets and keys in the data layer, IAM in every layer, and logging, alerts and recovery in the observability layer. The general mechanics of sensitive values (the sensitive flag, ephemeral values, write-only arguments, state encryption) are in Sensitive Data, Ephemeral Resources and Write-only Arguments; this part is about how they apply to Google Cloud resources.

Where secrets leak

The usual ways a Terraform project leaks a secret on Google Cloud, in order of how often they happen, are:

Anti-pattern Better

A password or API key written as a literal in a .tf file, committed to Git

A variable with no default, supplied through a TF_VAR_ environment variable, a secret store, or an ephemeral read. See Variables, Outputs and Locals.

A terraform.tfvars file with secret values, committed or copied between machines

.tfvars files for non-secret settings only; ignore files that hold anything else.

secret_data on google_secret_manager_secret_version, a password on google_sql_user, a google_service_account_key, a tls_private_key, a google_sql_ssl_cert

These write the value into state in plain text. Use the write-only arguments (secret_data_wo, password_wo), generate or read values outside Terraform, or avoid keys altogether.

State in a bucket that many people can read, or a state file archived by CI as an artefact

A restricted, encrypted, versioned bucket with locking; never archive state.

Marking a variable sensitive and believing it is out of state

sensitive only hides values in CLI output. The value is still stored in state and in saved plans unless the argument is write-only.

Secret Manager

A secret has two parts in Secret Manager and in Terraform. The secret (google_secret_manager_secret) is a named container with replication, labels, rotation and expiry settings. A version (google_secret_manager_secret_version) holds the payload. Terraform can manage both, but the payload is the part that must stay out of state. The service itself is covered in Secret Manager and KMS.

The arguments of the secret that matter most:

Argument Effect

secret_id and replication

The ID, and either auto {} (Google chooses the replication) or user_managed with explicit replicas, optionally with a customer-managed key per replica.

ttl or expire_time

Make the secret expire (a duration in seconds such as 2592000s, or an RFC 3339 timestamp). Only one can be set. They are for temporary secrets: a database password that expires silently takes the application down.

version_destroy_ttl

A delay between a destroy request on a version and its real destruction. During the delay the version is disabled, which gives a window to notice a mistake.

rotation and topics

Secret Manager publishes a notification to the configured Pub/Sub topics at next_rotation_time and then every rotation_period. It does not rotate anything by itself; something must subscribe, create a new version and change the system the secret protects. topics must be set for rotation.

annotations, labels, tags

Metadata. Annotations and labels are non-authoritative, like other labels in this provider.

deletion_protection

Prevents Terraform from destroying the secret; defaults to false.

For the payload there are two options. secret_data is sensitive in plan output but is stored in state. secret_data_wo is write-only: it is sent to the API and never stored, so a rotation is expressed by changing secret_data_wo_version, which Terraform does store and compare. The two are mutually exclusive. Provider 8.0.0 changed the contract: secret_data_wo_version became a string (existing state converts on the first apply), and it is required whenever secret_data_wo is set, with the old implicit default of 0 removed. If your configuration still has an integer there, quote it before upgrading.

variable "reviews_api_key" {
  type      = string
  sensitive = true
  ephemeral = true
}

resource "google_secret_manager_secret" "reviews_api_key" {
  project   = var.project_id
  secret_id = "bookshelf-${var.environment}-reviews-api-key"

  replication {
    auto {}
  }

  version_destroy_ttl = "86400s"

  topics {
    name = google_pubsub_topic.secret_events.id
  }

  rotation {
    rotation_period    = "7776000s" # 90 days
    next_rotation_time = "2027-01-15T00:00:00Z"
  }
}

resource "google_pubsub_topic" "secret_events" {
  project = var.project_id
  name    = "bookshelf-${var.environment}-secret-events"
}

resource "google_secret_manager_secret_version" "reviews_api_key" {
  secret                 = google_secret_manager_secret.reviews_api_key.id
  secret_data_wo         = var.reviews_api_key
  secret_data_wo_version = "1"
  deletion_policy        = "DISABLE"
}

For rotation notifications to be delivered, the Secret Manager service agent needs roles/pubsub.publisher on the topic, as the provider documents; that agent can be created with google_project_service_identity (a beta resource, as described below) and granted with google_pubsub_topic_iam_member.

Run it with TF_VAR_reviews_api_key in the environment (it is never printed because the variable is both sensitive and ephemeral), and increment secret_data_wo_version (a string) when you want a new version. deletion_policy set to DISABLE makes terraform destroy disable the version instead of destroying it, and ABANDON leaves it alone; the default destroys.

Two lifecycle points deserve care when secret_data (not the write-only argument) is used. Changing the data replaces the version resource, so a plain replacement destroys the old version before the new one exists unless you ask for the other order with lifecycle { create_before_destroy = true }; with that setting consumers that read latest never see a moment with no enabled version. And a secret that has a ttl or expire_time will disappear on schedule even though Terraform still lists it, so treat the expiry as part of the contract, not as a clean-up.

Reading secrets back into Terraform has the same two options. The google_secret_manager_secret_version data source puts the payload into state; the ephemeral resource with the same name does not (see the Cloud SQL user earlier). The only legitimate reason to use the data source is a value that is not secret.

Cloud KMS and customer-managed encryption keys

Google encrypts data at rest by default. Customer-managed encryption keys (CMEK) in Cloud KMS give you control of the key’s rotation, disablement and audit trail, and satisfy policies that require it. They are configured per resource: Cloud Storage buckets (encryption.default_kms_key_name), Cloud SQL (encryption_key_name), Pub/Sub topics (kms_key_name), BigQuery datasets (default_encryption_configuration), Secret Manager (customer_managed_encryption with kms_key_name, inside replication) and the state backend (kms_encryption_key).

Three properties of Cloud KMS shape the Terraform code. A key ring and a key cannot be deleted from a project; destroying the Terraform resource only removes it from state, and for a key it also deletes the key versions, after which anything encrypted with it is unrecoverable (versions pass through a scheduled-destruction period, 30 days by default, configurable with destroy_scheduled_duration when the key is created). The rotation_period must be longer than a day. And each Google service that uses your key does so through its own service agent, which must be granted roles/cloudkms.cryptoKeyEncrypterDecrypter on the key, before the dependent resource is created.

resource "google_kms_key_ring" "bookshelf" {
  project  = var.project_id
  name     = "bookshelf-${var.environment}"
  location = var.region
}

resource "google_kms_crypto_key" "data" {
  name            = "data"
  key_ring        = google_kms_key_ring.bookshelf.id
  purpose         = "ENCRYPT_DECRYPT"
  rotation_period = "7776000s"

  lifecycle {
    prevent_destroy = true
  }
}

data "google_storage_project_service_account" "gcs" {
  project = var.project_id
}

resource "google_kms_crypto_key_iam_member" "gcs" {
  crypto_key_id = google_kms_crypto_key.data.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = "serviceAccount:${data.google_storage_project_service_account.gcs.email_address}"
}

resource "google_storage_bucket" "exports" {
  project                     = var.project_id
  name                        = "bookshelf-exports-${var.environment}-${var.project_id}"
  location                    = var.region
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  encryption {
    default_kms_key_name = google_kms_crypto_key.data.id
  }

  depends_on = [google_kms_crypto_key_iam_member.gcs]
}

The key and the bucket must be in compatible locations. For services whose service agent is not exposed by a data source, the google_project_service_identity resource creates it. At the time of writing that resource is documented as beta, so it needs provider = google-beta, and once created it cannot be updated or destroyed (those actions do nothing). This is the typical reason to keep a google-beta provider block in an otherwise GA configuration:

resource "google_project_service_identity" "cloudsql" {
  provider = google-beta

  project = var.project_id
  service = "sqladmin.googleapis.com"
}

resource "google_kms_crypto_key_iam_member" "cloudsql" {
  crypto_key_id = google_kms_crypto_key.data.id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = google_project_service_identity.cloudsql.member
}

# Then on the instance: encryption_key_name = google_kms_crypto_key.data.id

Encryption in transit is configured separately and was shown earlier: ssl_mode = ENCRYPTED_ONLY on Cloud SQL, HTTPS-only Cloud Run endpoints, private Google access on subnets, and storage_custom_endpoint for the state backend.

IAM: authoritative or not

Google Cloud IAM resources in the provider come in three flavours for every kind of resource, and mixing them is the most common source of surprising plans:

Resource Behaviour

*_iam_policy

Authoritative for the whole policy of the target: replaces everything, including bindings added by other tools or by Google. Use it only on resources you fully own; on a project it can lock you out.

*_iam_binding

Authoritative for one role on the target: the members listed are the members, others are removed.

*_iam_member

Non-authoritative: adds one member to one role and leaves everything else alone. The default choice.

Together with the earlier advice, the practical rules are: grant roles with the member form; scope each grant as narrowly as possible (a role on a secret, a bucket, a service or a repository beats a role on the project); use predefined roles, and a custom role only when no predefined role fits; and never grant the basic Owner, Editor or Viewer roles to workloads. The provider’s member attribute on google_service_account produces the right serviceAccount: string.

A custom role and a conditional grant look like this. IAM conditions use Common Expression Language and apply to the binding they are attached to; the example grants a contractor temporary read access to logs.

resource "google_project_iam_custom_role" "bookshelf_operator" {
  project     = var.project_id
  role_id     = "bookshelfOperator"
  title       = "Bookshelf operator"
  description = "Restart Cloud Run revisions and read logs, nothing else"
  stage       = "GA"

  permissions = [
    "run.services.get",
    "run.services.list",
    "run.revisions.list",
    "logging.logEntries.list",
  ]
}

resource "google_project_iam_member" "contractor_logs" {
  project = var.project_id
  role    = "roles/logging.viewer"
  member  = "user:contractor@example.com"

  condition {
    title       = "expires-2027-01-01"
    description = "Temporary access for the audit"
    expression  = "request.time < timestamp(\"2027-01-01T00:00:00Z\")"
  }
}

The role_id of a custom role is camel case and cannot contain a hyphen. Custom roles are for a project or an organization and need stage to say whether they are in alpha, beta or general availability. The permissions list must be checked against the permissions the services actually need; use the IAM Recommender and the audit logs rather than guesswork.

The external data source, which the book uses to compute IAM values with scripts, is an escape hatch with a clear protocol: a program reads a JSON object of strings from standard input and prints a JSON object of strings on standard output, and runs on every refresh. Its documentation warns that it hurts portability and that it is not recommended in Terraform Enterprise. Almost everything IAM needs is available through provider resources and data sources (for example google_iam_policy, google_project, google_service_account), so reach for external last.

Securing the remote state

The state bucket is the most sensitive object of the whole configuration. Most of the work is in the bootstrap section; the checklist below connects the controls to Google’s guidance.

  • Access: only the deployer service accounts and a small administrator group hold roles/storage.objectAdmin or higher on the bucket. Developers get plan-only identities (read-only roles on resources, and the permission to read the state) if the pipeline cannot run plans for them.

  • Encryption: the default Google-managed encryption always applies; add a customer-managed key through kms_encryption_key for stricter control. Google’s security page also mentions customer-supplied keys through GOOGLE_ENCRYPTION_KEY, with the migration limitations described earlier. OpenTofu can additionally encrypt state client side.

  • Locking: provided by the backend; do not disable it with -lock=false in automation.

  • Versioning and soft delete: on, with a lifecycle cap, so recovery is possible without unbounded cost.

  • Audit: Data Access audit logs for Cloud Storage record reads of state objects; see the audit section below.

  • Separation: one bucket (or at least one prefix and IAM boundary) per trust boundary, as Google’s separation-of-duties guidance says. A network team’s state should not be readable by the application team that merely consumes its outputs.

  • Not in the repository: ignore .terraform/, *.tfstate* and saved plans, which hold the same secrets.

Audit logs, sinks and alerts

Google Cloud writes Admin Activity audit logs for administrative operations without configuration. Data Access audit logs, which record reads of data and of configuration, are disabled by default for all services except some BigQuery ones, are billed as log volume, and must be turned on per service, for example for Cloud Storage and Secret Manager. In Terraform that is google_project_iam_audit_config; a sink (google_logging_project_sink) routes matching log entries to a destination, and an alert policy reacts to metrics.

resource "google_project_iam_audit_config" "secrets_and_storage" {
  for_each = toset(["secretmanager.googleapis.com", "storage.googleapis.com"])

  project = var.project_id
  service = each.key

  audit_log_config {
    log_type = "DATA_READ"
  }

  audit_log_config {
    log_type = "DATA_WRITE"
  }
}

resource "google_storage_bucket" "audit_logs" {
  project                     = var.project_id
  name                        = "bookshelf-audit-${var.environment}-${var.project_id}"
  location                    = var.region
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  lifecycle_rule {
    condition {
      age = 365
    }
    action {
      type = "Delete"
    }
  }
}

resource "google_logging_project_sink" "audit" {
  project                = var.project_id
  name                   = "bookshelf-audit"
  destination            = "storage.googleapis.com/${google_storage_bucket.audit_logs.name}"
  filter                 = "logName:\"cloudaudit.googleapis.com\""
  unique_writer_identity = true
}

# A unique writer identity needs access to the destination.
resource "google_storage_bucket_iam_member" "audit_writer" {
  bucket = google_storage_bucket.audit_logs.name
  role   = "roles/storage.objectCreator"
  member = google_logging_project_sink.audit.writer_identity
}

resource "google_monitoring_notification_channel" "oncall" {
  project      = var.project_id
  display_name = "Bookshelf on-call"
  type         = "email"

  labels = {
    email_address = "oncall@example.com"
  }
}

resource "google_monitoring_alert_policy" "database_cpu" {
  project      = var.project_id
  display_name = "Bookshelf database CPU high"
  combiner     = "OR"

  conditions {
    display_name = "Cloud SQL CPU above 80 percent for 10 minutes"

    condition_threshold {
      filter          = "metric.type=\"cloudsql.googleapis.com/database/cpu/utilization\" AND resource.type=\"cloudsql_database\""
      comparison      = "COMPARISON_GT"
      threshold_value = 0.8
      duration        = "600s"

      aggregations {
        alignment_period   = "60s"
        per_series_aligner = "ALIGN_MEAN"
      }
    }
  }

  notification_channels = [google_monitoring_notification_channel.oncall.id]
}

Three notes. First, project_iam_audit_config is authoritative for a given service: it replaces the existing audit configuration of that service, so declare it in one place only. Second, the sink’s writer_identity is only known after creation, which is why the bucket grant is a separate resource; the sink can start before the grant exists and drop entries until it does, so keep the dependency. Third, the metric type and resource type in the filter are those of Cloud SQL; check them against the Metrics Explorer for the service you alert on. The monitoring section of the Google Cloud pages, Monitoring and observability, covers metrics, log-based metrics and uptime checks in depth. Security Health Analytics, mentioned in Google’s best practices as a post-apply audit, and gcloud beta terraform vet as a pre-apply check, are the Google-native counterparts of the scanners in Code Quality and Security Scanning.

Resilience and recovery

A Terraform configuration is a recovery plan for infrastructure, not for data. Plan for four kinds of loss:

Loss Mechanism

The database

Automated backups with a retention count, point-in-time recovery, regional high availability, and a read replica in another region for disaster recovery (all shown above). Restoring is an operation on the instance, not a Terraform action.

A VM’s disk

A snapshot schedule: a google_compute_resource_policy with a snapshot_schedule_policy (a daily schedule, a retention in days, snapshot labels and storage location), attached to each disk with google_compute_disk_resource_policy_attachment. on_source_disk_delete defaults to KEEP_AUTO_SNAPSHOTS.

Objects

Versioning and soft delete on the bucket, a lifecycle rule that caps the versions, and a backup bucket in a different location that a transfer job or the application fills. Lifecycle rules can also move old objects to colder storage classes with SetStorageClass.

The configuration and the state

Git for the former, a versioned bucket for the latter; the whole project can be rebuilt by applying the layers in order against an empty project. Test that claim in a throwaway project at least once.

DNS plays a part in failover: users reach the Bookshelf through a name, so keep the TTL of the records that point at it low enough that a switch to another region takes effect quickly, and manage the zone and record sets in their own layer (google_dns_managed_zone, google_dns_record_set) so that rebuilding an application layer never touches them. Cloud DNS is described in Cloud DNS and certificates.

Clean up

Everything above creates billable resources: Cloud SQL with high availability and a replica, Cloud Run, Pub/Sub, BigQuery, Cloud KMS keys, VMs and buckets. Destroy a practice environment as soon as you finish. Because the examples deliberately resist deletion, destroying them is a short procedure, not a single command.

  1. Lower the protections first, and apply that change. Protection arguments are checked against state, so changing them and destroying in one step does not work. The arguments are deletion_protection on google_cloud_run_v2_service, google_sql_database_instance, google_bigquery_table and google_container_cluster; settings.deletion_protection_enabled on Cloud SQL; force_destroy on buckets that still hold objects; and deletion_policy where it is PREVENT. The simplest way is a variable that every protected resource reads.

  2. Destroy the layers in reverse order: observability, apps, data, network, bootstrap.

  3. Deal with the resources that cannot be deleted. Cloud KMS key rings and keys stay in the project; destroying the Terraform resource schedules the key versions for destruction (30 days by default) and makes anything encrypted with the key unrecoverable. If the key must outlive the configuration, forget it with a removed block instead.

  4. Handle the private services connection. The VPC peering behind google_service_networking_connection is not a separate resource and can block deletion of the network. Set deletion_policy to REMOVE_PEERING (or ABANDON, which leaves the peering in place) and apply before destroying.

  5. Destroy the state bucket last, after migrating the bootstrap configuration’s state back to local.

variable "protect_resources" {
  type    = bool
  default = true
}

# On each protected resource:
#   deletion_protection = var.protect_resources
#   (Cloud SQL also: settings { deletion_protection_enabled = var.protect_resources })
#   (buckets: force_destroy = !var.protect_resources)

resource "google_service_networking_connection" "private_services" {
  network                 = google_compute_network.bookshelf.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_services.name]
  deletion_policy         = var.protect_resources ? "DELETE" : "REMOVE_PEERING"
}

# Keep the encryption key, drop it from state
removed {
  from = google_kms_crypto_key.data

  lifecycle {
    destroy = false
  }
}
# 1. Apply the unprotected settings
terraform apply -var="protect_resources=false"

# 2. Destroy each layer, last created first
for layer in observability apps data network; do
  (cd "environments/dev/${layer}" && terraform destroy -var="protect_resources=false")
done

# 3. Bootstrap: move its state back to local, then destroy the state bucket
cd bootstrap
# remove the backend block from the configuration, then:
terraform init -migrate-state
terraform destroy -var="protect_resources=false"

# 4. Infrastructure Manager deployments created for the exercise
gcloud infra-manager deployments delete \
  "projects/my-project-id/locations/us-central1/deployments/bookshelf-covers"
gcloud infra-manager previews delete \
  "projects/my-project-id/locations/us-central1/previews/bookshelf-covers-preview"

# 5. Check nothing billable is left
gcloud run services list --project=my-project-id
gcloud sql instances list --project=my-project-id
gcloud storage buckets list --project=my-project-id
gcloud kms keyrings list --location=us-central1 --project=my-project-id

For a throwaway project, shutting the whole project down removes everything at once, with a recovery window before final deletion; a google_project managed by Terraform has deletion_policy defaulting to PREVENT since 6.0.0, so set it to DELETE first, or use ABANDON to drop it from state. Delete Artifact Registry repositories, Cloud Logging buckets and the audit bucket if the destroy skipped them, and remember that secrets, keys and BigQuery data may have retention periods.

What changed recently

When Change

Provider 5.16.0, 6.0.0

The goog-terraform-provisioned attribution label: opt-in in 5.16.0, added to new resources by default from 6.0.0.

Terraform 1.10, 1.11

Ephemeral resources, variables and outputs (1.10) and write-only arguments (1.11). Provider 6.23.0 added password_wo to google_sql_user, and the Google provider gained ephemeral resources for tokens, keys and Secret Manager versions.

August 2024

Cloud Functions 2nd generation became Cloud Run functions; the Terraform resource is still google_cloudfunctions2_function.

Provider 6.0.0 (2024-08-26)

Deletion protection on Cloud Run v2 services and jobs, project deletion_policy defaulting to PREVENT, ssl_mode instead of require_ssl.

Provider 6.28.0 (2025-04-01)

external_credentials in the provider block.

2025-03-18

Container Registry shut down; use Artifact Registry.

Provider 7.0.0 (2025-08-26)

disable_on_destroy without default, event_type required on 2nd generation functions, password_wo and password_wo_version as a pair.

2026-04-01

Deployment Manager support discontinued; Infrastructure Manager is the replacement.

Provider 8.0.0 (2026-08-26)

Removed BeyondCorp, IAP brand and client, ML Engine and notebooks resources; secret_data_wo_version required and a string; EXTERNAL_MANAGED as the default load balancing scheme.

Provider 8.8.0 (2026-10-21)

Latest at the time of writing; adds google_cloud_run_v2_instance and its IAM resources among others.

Terraform and OpenTofu on Google Cloud

The Google providers are the same code for both tools, installed from the Terraform registry or the OpenTofu registry, and authentication, impersonation and federation behave identically. The differences are around them:

  • The OpenTofu gcs backend documents one more argument, universe_domain, and OpenTofu adds client-side state and plan encryption, which can be combined with the bucket’s own encryption.

  • Infrastructure Manager runs Terraform; its documentation lists only Terraform versions, so OpenTofu cannot be selected there.

  • Module version constraints compare version numbers of the tool. The Fabric modules require Terraform >= 1.12.2; the numbers of OpenTofu and Terraform are not comparable, so test modules with the tool you use instead of trusting the constraint.

Where the book is outdated or wrong

  • hashicorp/google pinned exactly to 5.38.0 (Wang). Use the current major with a ~> constraint and the lock file.

  • Retired resources and images (Wang): google_cloudfunctions_function (1st generation), google_cloud_run_service, gcr.io images, debian-10, and MySQL 5.7. Use google_cloudfunctions2_function, google_cloud_run_v2_service, Artifact Registry, debian-13 and a current PostgreSQL or MySQL major.

  • A service account with the editor role plus a downloaded JSON key (Wang). Use least-privilege roles with impersonation and Workload Identity Federation.

  • secret_data stored in state (Wang). Use write-only arguments and ephemeral reads.

  • GKE master authorized networks open to everyone (Wang). Restrict them to real administrator ranges.

  • A gcs backend with key and region (Wang). The backend takes bucket and prefix.

  • state pull and state push to adopt a remote backend (Wang). Use terraform init -migrate-state.

  • tf.tfvars loaded automatically (Wang). Only terraform.tfvars, terraform.tfvars.json and .auto.tfvars files are.

Pitfalls

  • Pinning nothing, or everything: use ~> constraints, commit the lock file, and read the upgrade guide before a major.

  • Destroying a google_project_service on a provider older than 7.0.0 with the default disable_on_destroy.

  • Leaving edition unset on PostgreSQL 16 or later with a custom tier.

  • Applying a Cloud Run change that CI also deploys, so each tool reverts the other’s image.

  • Granting roles/run.invoker to allUsers by habit.

  • Expecting a *_iam_policy resource to add to existing policy; it replaces it.

  • Using GOOGLE_CREDENTIALS for the provider and forgetting that the backend reads it too.

  • Deploying modules with a newer Terraform floor to Infrastructure Manager.

  • Forgetting that a destroyed KMS key makes data unrecoverable.

  • Running the first apply of an ephemeral secret read before the secret has a version.