Cloud Engineering · Infrastructure as Code : TERRAFORM GUIDE with GCP

Cloud Engineering · Infrastructure as Code · ~28 min read

Provisioning Google Cloud APIs the Right Way: A Complete Terraform Walkthrough (Modules, IAM/WIF Deep-Dive, and Tests)

Most Terraform tutorials for Google Cloud jump straight into resources — a bucket here, a Cloud Run service there — and quietly skip the step that breaks everything on a fresh project: enabling the underlying APIs first, and understanding how those APIs actually trust each other through IAM. If you've ever seen Error 403: API [x] not enabled on project [y], or spent an afternoon debugging why a GitHub Actions job can't authenticate to GCP, this post is for you. We're going to build a real, working use case — a serverless event-driven data pipeline — entirely with Terraform, structured as reusable modules rather than one flat file, enabling every API it needs, explaining the parameters on every resource we write, and mapping out exactly how IAM and Workload Identity Federation (WIF) connect everything together. Then we'll test the infrastructure itself, and ship it through CI/CD.

What you'll build: A pipeline where a file dropped in Cloud Storage triggers a Pub/Sub notification, which invokes a Cloud Run service, which writes structured records into BigQuery — all provisioned as Terraform modules, IAM-scoped with least privilege, authenticated in CI via Workload Identity Federation (no service account keys), and tested with both native terraform test and Terratest.

Table of Contents

  1. Why Terraform? (and how you'd do this without it)
  2. Prerequisites
  3. gcloud CLI Basics You'll Actually Use
  4. The Core GCP APIs
  5. API & IAM Architecture Deep-Dive (incl. Workload Identity Federation)
  6. Project Structure — Modules over Monolith
  7. Module: apis
  8. Module: iam & WIF
  9. Module: storage
  10. Module: pubsub
  11. Module: bigquery
  12. Module: cloud_run
  13. Root Module — Wiring It All Together
  14. Plan & Apply
  15. Testing (native terraform test + Terratest)
  16. CI/CD with GitHub Actions via WIF
  17. Common Pitfalls & Debugging Notes
  18. Cleanup
  19. Conclusion

1. Why Terraform? (and how you'd do this without it)

Before writing a single .tf file, it's worth being honest about what problem Terraform is actually solving here, because everything in this pipeline — the API enablement, the IAM bindings, the WIF trust chain, the Cloud Run service — is entirely possible to build by hand. People do it every day. The question is what you give up when you do.

1.1 What "infrastructure as code" actually buys you

  • A single source of truth. The .tf files are the specification of what exists. You don't have to reconstruct the current state of the project from memory, from a wiki page, or by clicking through the console and hoping nothing was missed.
  • A dependency graph, computed for you. Our pipeline has real ordering constraints — APIs before service accounts, service accounts before IAM bindings, the Pub/Sub topic before the GCS notification. Terraform builds this graph from resource references automatically and applies changes in the correct order; you never hand-sequence sixteen gcloud commands and hope you got the order right.
  • Preview before commit. terraform plan shows exactly what will be created, changed, or destroyed before anything happens. There's no equivalent "dry run" for a sequence of gcloud/console actions — you either do them or you don't.
  • Idempotency. Running terraform apply twice in a row does nothing the second time if nothing changed. A shell script of gcloud create commands errors out on the second run because the resource already exists, unless you litter it with existence checks.
  • Reproducibility across environments. The modules/ + environments/ structure from this post means dev and prod are guaranteed to be structurally identical, because they call the same module code with different variables — not two independently maintained sets of console clicks that quietly drift apart.
  • A reviewable diff. A pull request changing a Terraform file is something a teammate can read and reason about before it touches a real project. A change made by someone clicking through the Cloud Console leaves no diff at all.
  • Safe, complete teardown. terraform destroy removes exactly what Terraform created, in dependency-safe order. Manually tracing "everything this pipeline touched" to delete it later is exactly the kind of task humans get wrong.

1.2 How you'd build this same pipeline without Terraform

It's a useful exercise precisely because it's not impossible — it's just a different set of trade-offs. Three realistic alternatives:

Approach What it looks like What you lose vs. Terraform
Manual, via Cloud Console / gcloud Click through the console, or run the gcloud commands from section 3 in order, by hand, every time you need this pipeline in a new project. No diff, no plan, no record of what was actually done, easy to fat-finger a role or region, painful to replicate for a second environment, nothing to roll back to.
Shell scripts of gcloud commands A setup.sh that runs the same gcloud calls in sequence, checked into git. Better than nothing — it's at least versioned — but you own idempotency yourself (every command needs an if resource exists, skip guard), there's no dependency graph, no plan/preview, and a partial failure halfway through leaves the project in an undocumented, non-obvious state.
Google Cloud Deployment Manager / Config Connector Deployment Manager uses YAML/Python templates evaluated server-side by Google; Config Connector manages GCP resources as Kubernetes custom resources if you're already running GKE. Both give you real IaC properties (declarative, diffable), but Deployment Manager is GCP-only — no multi-cloud story, a smaller module ecosystem, and Google has been steering users toward Terraform for new projects. Config Connector is excellent if you're already deep in Kubernetes, but is a heavy dependency to adopt just for a project like this one.

The honest reason to reach for Terraform specifically, rather than Deployment Manager or raw scripts, is the ecosystem: the hashicorp/google provider tracks new GCP resources quickly, the module registry means you're rarely starting from a blank page, and the exact same tool and workflow apply if a future project needs AWS, Cloudflare, or Datadog resources alongside GCP — none of the manual or GCP-only alternatives give you that.

2. Prerequisites

  • A GCP project with billing enabled
  • gcloud CLI installed and authenticated
  • Terraform ≥ 1.7 (native terraform test requires ≥ 1.6)
  • Go ≥ 1.21 (only for the Terratest examples)
  • Docker (to build the Cloud Run container image)
  • A GitHub repository with Actions enabled

3. gcloud CLI Basics You'll Actually Use

Terraform manages the desired state, but you still need gcloud for bootstrapping, authentication, and day-to-day inspection. Here's the working set.

Auth & project context

# Interactive login for your user account (used by gcloud commands)
gcloud auth login

# Separate credential Terraform/SDKs pick up automatically (ADC)
gcloud auth application-default login

# See who / what project you're currently authenticated as
gcloud auth list
gcloud config list

# Set the active project for all subsequent commands
gcloud config set project YOUR_PROJECT_ID

# Set a default region/zone so you stop typing --region every time
gcloud config set compute/region europe-west1
gcloud config set run/region europe-west1

Projects & billing

gcloud projects list
gcloud projects describe YOUR_PROJECT_ID
gcloud projects create YOUR_PROJECT_ID --name="Pipeline Demo"

# Billing must be linked before most APIs will actually enable
gcloud billing accounts list
gcloud billing projects link YOUR_PROJECT_ID --billing-account=BILLING_ACCOUNT_ID

Services (APIs) — the manual equivalent of google_project_service

# List currently enabled APIs
gcloud services list --enabled

# Search the catalog of available APIs
gcloud services list --available --filter="name:run"

# Enable / disable one API by hand (useful when debugging Terraform)
gcloud services enable run.googleapis.com
gcloud services disable run.googleapis.com

# Enable several at once
gcloud services enable storage.googleapis.com pubsub.googleapis.com bigquery.googleapis.com

IAM & service accounts

gcloud iam service-accounts list
gcloud iam service-accounts create pipeline-runner --display-name="Pipeline Cloud Run SA"
gcloud iam service-accounts describe pipeline-runner@YOUR_PROJECT_ID.iam.gserviceaccount.com

# Grant a role at the project level
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:pipeline-runner@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataEditor"

# Inspect the full policy (what Terraform's google_project_iam_member calls modify)
gcloud projects get-iam-policy YOUR_PROJECT_ID --format=json

Workload Identity Federation (setup you'll usually still do once by hand or bootstrap-Terraform)

gcloud iam workload-identity-pools list --location=global
gcloud iam workload-identity-pools describe github-pool --location=global
gcloud iam workload-identity-pools providers list \
  --workload-identity-pool=github-pool --location=global

Everyday inspection (Storage, Pub/Sub, BigQuery, Cloud Run)

gsutil ls
gsutil ls gs://YOUR_PROJECT_ID-landing

gcloud pubsub topics list
gcloud pubsub subscriptions list
gcloud pubsub topics publish file-events --message='{"test":true}'

bq ls
bq show YOUR_PROJECT_ID:pipeline_events.file_events
bq query --use_legacy_sql=false 'SELECT COUNT(*) FROM `pipeline_events.file_events`'

gcloud run services list
gcloud run services describe pipeline-processor --region=europe-west1
gcloud run services logs read pipeline-processor --region=europe-west1 --limit=50

4. The Core GCP APIs

API Service Endpoint Why we need it
Cloud Resource Managercloudresourcemanager.googleapis.comLets Terraform manage project-level metadata and enable other APIs
Service Usageserviceusage.googleapis.comThe API that all other google_project_service calls go through
IAMiam.googleapis.comService accounts, custom roles, policy bindings
IAM Credentialsiamcredentials.googleapis.comIssues short-lived tokens — what makes WIF impersonation work
Security Token Servicests.googleapis.comExchanges an external OIDC token for a Google STS token during WIF
Cloud Storagestorage.googleapis.comLanding bucket + Terraform remote state
Pub/Subpubsub.googleapis.comEvent notifications from GCS to our service
Cloud Run Adminrun.googleapis.comDeploys and manages the serverless processing container
Artifact Registryartifactregistry.googleapis.comStores the container image Cloud Run deploys
Cloud Buildcloudbuild.googleapis.comBuilds the container image in CI
BigQuerybigquery.googleapis.comDestination warehouse for processed records

5. API & IAM Architecture Deep-Dive

Enabling APIs is necessary but not sufficient — each API's identity has to be granted permission to call the others, and your own automation has to be granted permission to act as those identities. There are three distinct trust relationships happening in this pipeline, and conflating them is the single biggest source of confusion.

4.1 Layer 1 — API-to-API trust (Google-managed service identities)

Several Google services act on your behalf using an automatically provisioned "service agent" identity, distinct from any service account you create. In our pipeline:

  • Cloud Storage → Pub/Sub: GCS publishes bucket-change notifications using the identity service-<PROJECT_NUMBER>@gs-project-accounts.iam.gserviceaccount.com. This identity needs roles/pubsub.publisher on the topic — not your own service account, Google's.
  • Pub/Sub → Cloud Run: a push subscription needs to invoke your private Cloud Run service. It does this by attaching an OIDC token signed by a service account you specify in the subscription's oidc_token block, and that service account needs roles/run.invoker on the service.
  • Cloud Build → Artifact Registry: the Cloud Build service agent (<PROJECT_NUMBER>@cloudbuild.gserviceaccount.com) needs roles/artifactregistry.writer to push images.

4.2 Layer 2 — Application identity (the Cloud Run runtime service account)

This is the service account attached directly to the Cloud Run revision (google_service_account.pipeline_runner in our code). It's the identity your application code runs as, and it should hold only the roles the running container actually needs at runtime: roles/bigquery.dataEditor to write rows, and nothing broader. This is a completely separate concern from how CI/CD authenticates to deploy the infrastructure in the first place — that's Layer 3.

4.3 Layer 3 — Workload Identity Federation (how GitHub Actions authenticates without a key file)

Before WIF, the standard pattern was: create a service account, download a long-lived JSON key, paste it into a GitHub secret. That key never expires on its own, it's a standing credential sitting in plaintext in your CI system, and if it leaks, it's a full compromise until someone manually revokes it. WIF removes the key entirely by letting Google directly trust GitHub's own token issuer.

The chain of trust has four pieces, and each one is a real GCP resource:

Component Role in the chain
Workload Identity PoolA container for external identity providers. Nothing is trusted yet at this level — it's just a namespace.
Workload Identity ProviderRegistered inside the pool, this points at GitHub's OIDC issuer (https://token.actions.githubusercontent.com) and defines attribute mapping (which claims in GitHub's token become Google attributes) and an attribute condition (a CEL expression restricting which tokens are accepted at all — e.g. only from your specific repo).
IAM binding (roles/iam.workloadIdentityUser)Grants a specific principal set from the pool (e.g. "tokens whose repository attribute equals org/repo") permission to impersonate a real GCP service account. This is the actual trust decision.
GCP Service AccountThe identity that ends up making Terraform API calls. It never has a downloadable key — it's only ever reached via short-lived impersonation tokens issued through STS.

At runtime the flow is: GitHub Actions requests a short-lived OIDC token from GitHub → presents it to Google's Security Token Service, which validates it against the Provider's issuer and attribute condition → STS exchanges it for a federated token → that federated token is used to impersonate the bound service account via IAM Credentials → Terraform runs as that service account using a token that expires in about an hour and was never stored anywhere.

The diagram below traces that same chain top to bottom — the external actor (gray) hands a token to the WIF layer (purple), which resolves into the identity and trust layer (teal), which is what actually holds the granted IAM roles (coral) that unlock real GCP resources. Click any box to jump to the section that covers it in detail.

Workload Identity Federation trust chain Diagram showing how a GitHub Actions OIDC token flows through a workload identity pool and provider, an IAM binding, a service account, and granted IAM roles, down to the GCP resources those roles unlock. GitHub Actions workflow requests an OIDC token Workload identity pool github-pool — trust namespace Workload identity provider github-provider — OIDC issuer Claim mapping Repo filter IAM binding roles/iam.workloadIdentityUser Service account pipeline-runner@project.iam BigQuery access dataEditor role Cloud Run invoke run.invoker role BigQuery dataset Cloud Run service External actor WIF federation Identity and trust Granted roles
Why this matters for the API list above: WIF itself depends on three APIs being enabled — iam.googleapis.com (to define the pool/provider and bindings), iamcredentials.googleapis.com (to actually impersonate), and sts.googleapis.com (to perform the token exchange). If any of the three is missing, authentication fails with an error that rarely mentions which of the three is the actual cause.

6. Project Structure — Modules over Monolith

A single flat main.tf works for a demo, but stops scaling the moment you need a second environment (staging/prod) or want to reuse the pipeline logic elsewhere. We split each concern into its own module with its own variables.tf (inputs), main.tf (resources), and outputs.tf (values exposed upward).

gcp-terraform-pipeline/
├── environments/
│   ├── dev/
│   │   ├── main.tf              # root module for dev, calls modules/*
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf
│       └── terraform.tfvars
├── modules/
│   ├── apis/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── iam/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── storage/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── pubsub/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── bigquery/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── cloud_run/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── tests/
│   ├── apis.tftest.hcl
│   ├── storage.tftest.hcl
│   └── integration_test.go
├── service/
│   ├── main.py
│   ├── requirements.txt
│   └── Dockerfile
└── .github/workflows/terraform.yml

Each environment folder (dev, prod) is a separate root module with its own state file and its own terraform.tfvars, but calls the exact same shared modules — so a bug fix in modules/pubsub benefits both environments the moment it's applied.

7. Module: apis

# modules/apis/variables.tf
variable "project_id" {
  type        = string
  description = "GCP project where APIs will be enabled."
}

variable "apis" {
  type        = list(string)
  description = "List of service endpoints to enable, e.g. run.googleapis.com."
}

# modules/apis/main.tf
resource "google_project_service" "enabled" {
  for_each = toset(var.apis)

  project = var.project_id   # which project this API is turned on in
  service = each.value        # the specific API endpoint being enabled

  disable_on_destroy         = false  # keep the API on for the project after `terraform destroy`
  disable_dependent_services = false  # don't cascade-disable APIs that depend on this one
}

# modules/apis/outputs.tf
output "enabled_apis" {
  description = "Set of google_project_service resources, used by other modules as a depends_on target."
  value       = google_project_service.enabled
}

Parameters explained: for_each = toset(var.apis) turns a plain list into a set so Terraform can track each API as an independently addressable resource instance (google_project_service.enabled["run.googleapis.com"]) rather than a single blob — this means adding one new API to the list doesn't touch the other nine in the plan. disable_on_destroy = false is the parameter that prevents a teardown from disabling shared APIs on a project other infrastructure might depend on.

8. Module: iam & WIF

# modules/iam/variables.tf
variable "project_id"      { type = string }
variable "project_number"  { type = string }
variable "github_repo"     { type = string }  # format: "org/repo"

# modules/iam/main.tf

# Layer 2: the runtime identity Cloud Run executes as
resource "google_service_account" "pipeline_runner" {
  account_id   = "pipeline-runner"       # becomes pipeline-runner@PROJECT_ID.iam.gserviceaccount.com
  display_name = "Pipeline Cloud Run Service Account"
}

resource "google_project_iam_member" "bq_writer" {
  project = var.project_id
  role    = "roles/bigquery.dataEditor"                       # exact permission set granted
  member  = "serviceAccount:${google_service_account.pipeline_runner.email}"  # who receives it
}

# Layer 1: lets GCS's own service identity publish into our Pub/Sub topic
resource "google_project_iam_member" "gcs_pubsub_publisher" {
  project = var.project_id
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:service-${var.project_number}@gs-project-accounts.iam.gserviceaccount.com"
}

# Layer 3: Workload Identity Federation for GitHub Actions

resource "google_iam_workload_identity_pool" "github_pool" {
  workload_identity_pool_id = "github-pool"     # internal ID, referenced by the provider below
  display_name              = "GitHub Actions Pool"
  description                = "Trust boundary for GitHub-issued OIDC tokens"
}

resource "google_iam_workload_identity_pool_provider" "github_provider" {
  workload_identity_pool_id         = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"          # maps GitHub's token subject to Google's subject
    "attribute.repository" = "assertion.repository"    # exposes the repo claim as a usable attribute
    "attribute.ref"        = "assertion.ref"            # exposes the branch/tag claim
  }

  # Restricts which tokens are accepted at all -- narrows the pool to only this repo
  attribute_condition = "assertion.repository == '${var.github_repo}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

# The actual trust decision: this specific repo's tokens may impersonate pipeline_runner
resource "google_service_account_iam_member" "wif_binding" {
  service_account_id = google_service_account.pipeline_runner.name
  role                = "roles/iam.workloadIdentityUser"
  member              = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_pool.name}/attribute.repository/${var.github_repo}"
}

# modules/iam/outputs.tf
output "pipeline_runner_email" {
  value = google_service_account.pipeline_runner.email
}

output "workload_identity_provider" {
  description = "Full resource name passed to google-github-actions/auth in CI."
  value       = google_iam_workload_identity_pool_provider.github_provider.name
}

Parameters explained:

  • attribute_mapping is a dictionary translating claims inside GitHub's OIDC token (the assertion.* namespace) into Google-recognized attributes. Without mapping attribute.repository, you'd have no attribute to condition on or bind against later.
  • attribute_condition is evaluated on every token exchange attempt, before any IAM binding is even consulted — it's the first line of defense, rejecting tokens from any repository other than the one you named.
  • The member on google_service_account_iam_member uses principalSet:// rather than serviceAccount: or user: — this is what makes it a federated binding: it grants access to the whole set of external identities matching that attribute value, not one individual account.
  • role = "roles/iam.workloadIdentityUser" is applied on the service account itself (via service_account_iam_member), not on the project — it specifically means "impersonate this SA," and is intentionally separate from whatever project-level roles that SA holds.

9. Module: storage

# modules/storage/variables.tf
variable "project_id"      { type = string }
variable "region"          { type = string }
variable "pubsub_topic_id" { type = string }  # topic to notify on new objects

# modules/storage/main.tf
resource "google_storage_bucket" "landing" {
  name                        = "${var.project_id}-landing"
  location                    = var.region
  uniform_bucket_level_access = true   # disables legacy per-object ACLs, IAM-only access
  force_destroy               = true   # allows `terraform destroy` to delete a non-empty bucket

  lifecycle_rule {
    condition { age = 30 }             # matches objects older than 30 days
    action { type = "Delete" }          # ...and deletes them
  }
}

resource "google_storage_notification" "landing_notification" {
  bucket         = google_storage_bucket.landing.name
  payload_format = "JSON_API_V1"       # notification body format Pub/Sub subscribers will parse
  topic          = var.pubsub_topic_id
  event_types    = ["OBJECT_FINALIZE"] # fires only when an object upload completes, not on delete/metadata changes
}

# modules/storage/outputs.tf
output "bucket_name" {
  value = google_storage_bucket.landing.name
}

10. Module: pubsub

# modules/pubsub/variables.tf
variable "push_endpoint"           { type = string }  # Cloud Run URL to invoke
variable "invoker_service_account" { type = string }  # SA whose OIDC token authorizes the push

# modules/pubsub/main.tf
resource "google_pubsub_topic" "file_events" {
  name = "file-events"
}

resource "google_pubsub_topic" "file_events_dlq" {
  name = "file-events-dlq"   # dead-letter target for messages that repeatedly fail delivery
}

resource "google_pubsub_subscription" "file_events_push" {
  name  = "file-events-push"
  topic = google_pubsub_topic.file_events.id

  push_config {
    push_endpoint = var.push_endpoint          # where Pub/Sub sends the HTTP POST
    oidc_token {
      service_account_email = var.invoker_service_account  # signs the token Cloud Run verifies
    }
  }

  ack_deadline_seconds = 30   # time the subscriber has to ack before Pub/Sub retries delivery

  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.file_events_dlq.id
    max_delivery_attempts = 5   # after 5 failed attempts, redirect to the DLQ instead of retrying forever
  }
}

# modules/pubsub/outputs.tf
output "topic_id" {
  value = google_pubsub_topic.file_events.id
}

11. Module: bigquery

# modules/bigquery/variables.tf
variable "region" { type = string }

# modules/bigquery/main.tf
resource "google_bigquery_dataset" "pipeline" {
  dataset_id                 = "pipeline_events"
  location                   = var.region
  delete_contents_on_destroy = true   # allows destroy even if the dataset holds data (dev convenience, not for prod)
}

resource "google_bigquery_table" "events" {
  dataset_id = google_bigquery_dataset.pipeline.dataset_id
  table_id   = "file_events"

  schema = jsonencode([
    { name = "file_name",   type = "STRING",    mode = "REQUIRED" },  # REQUIRED = NOT NULL
    { name = "bucket",      type = "STRING",    mode = "REQUIRED" },
    { name = "size_bytes",  type = "INTEGER",   mode = "NULLABLE" },
    { name = "ingested_at", type = "TIMESTAMP", mode = "REQUIRED" },
  ])
}

# modules/bigquery/outputs.tf
output "dataset_id" { value = google_bigquery_dataset.pipeline.dataset_id }
output "table_id"   { value = google_bigquery_table.events.table_id }

12. Module: cloud_run

# modules/cloud_run/variables.tf
variable "region"             { type = string }
variable "service_account"    { type = string }
variable "image"              { type = string }
variable "bq_dataset"         { type = string }
variable "bq_table"           { type = string }

# modules/cloud_run/main.tf
resource "google_artifact_registry_repository" "pipeline" {
  location      = var.region
  repository_id = "pipeline"
  format        = "DOCKER"   # registry format; also supports MAVEN, NPM, PYTHON, etc.
}

resource "google_cloud_run_v2_service" "processor" {
  name     = "pipeline-processor"
  location = var.region
  ingress  = "INGRESS_TRAFFIC_INTERNAL_ONLY"   # blocks public internet, only internal/Pub-Sub traffic allowed

  template {
    service_account = var.service_account   # identity the running container assumes (Layer 2)

    containers {
      image = var.image

      env {
        name  = "BQ_DATASET"
        value = var.bq_dataset
      }
      env {
        name  = "BQ_TABLE"
        value = var.bq_table
      }

      resources {
        limits = {
          cpu    = "1"       # vCPU allocated per instance
          memory = "512Mi"   # memory ceiling per instance
        }
      }
    }

    scaling {
      min_instance_count = 0   # scales to zero when idle -- no cost at rest
      max_instance_count = 3   # hard ceiling to cap runaway cost/concurrency
    }
  }
}

resource "google_cloud_run_v2_service_iam_member" "invoker" {
  name     = google_cloud_run_v2_service.processor.name
  location = var.region
  role     = "roles/run.invoker"                         # permission to call this specific service
  member   = "serviceAccount:${var.service_account}"       # only the pipeline's own SA may invoke it
}

# modules/cloud_run/outputs.tf
output "service_uri" {
  value = google_cloud_run_v2_service.processor.uri
}

13. Root Module — Wiring It All Together

# environments/dev/main.tf
data "google_project" "current" {
  project_id = var.project_id
}

module "apis" {
  source     = "../../modules/apis"
  project_id = var.project_id
  apis = [
    "cloudresourcemanager.googleapis.com",
    "serviceusage.googleapis.com",
    "iam.googleapis.com",
    "iamcredentials.googleapis.com",
    "sts.googleapis.com",
    "storage.googleapis.com",
    "pubsub.googleapis.com",
    "run.googleapis.com",
    "artifactregistry.googleapis.com",
    "cloudbuild.googleapis.com",
    "bigquery.googleapis.com",
  ]
}

module "iam" {
  source         = "../../modules/iam"
  project_id     = var.project_id
  project_number = data.google_project.current.number
  github_repo    = var.github_repo
  depends_on     = [module.apis]   # module-level dependency: iam waits for every API to be enabled
}

module "bigquery" {
  source     = "../../modules/bigquery"
  region     = var.region
  depends_on = [module.apis]
}

module "storage" {
  source          = "../../modules/storage"
  project_id      = var.project_id
  region          = var.region
  pubsub_topic_id = module.pubsub.topic_id
}

module "pubsub" {
  source                   = "../../modules/pubsub"
  push_endpoint            = module.cloud_run.service_uri
  invoker_service_account  = module.iam.pipeline_runner_email
}

module "cloud_run" {
  source           = "../../modules/cloud_run"
  region           = var.region
  service_account  = module.iam.pipeline_runner_email
  image            = "${var.region}-docker.pkg.dev/${var.project_id}/pipeline/processor:latest"
  bq_dataset       = module.bigquery.dataset_id
  bq_table         = module.bigquery.table_id
}

Notice the values flowing between modules purely through outputs and variables — module.iam.pipeline_runner_email feeds both pubsub (for the OIDC invoker) and cloud_run (for the runtime identity), and module.cloud_run.service_uri feeds back into pubsub as the push endpoint. Terraform resolves this dependency graph automatically; you never sequence modules by hand.

14. Plan & Apply

cd environments/dev
terraform init
terraform fmt -recursive
terraform validate
terraform plan -var-file=terraform.tfvars -out=tfplan
terraform apply tfplan

15. Testing

A. Native terraform test

# tests/apis.tftest.hcl
variables {
  project_id  = "test-project-id"
  region      = "europe-west1"
  github_repo = "your-org/gcp-terraform-pipeline"
}

run "required_apis_are_enabled" {
  command = plan

  assert {
    condition     = length(module.apis.enabled_apis) == 11
    error_message = "Expected 11 GCP APIs to be enabled, got a different count."
  }
}

run "wif_provider_restricts_to_correct_repo" {
  command = plan

  assert {
    condition     = strcontains(module.iam.workload_identity_provider, "github-provider")
    error_message = "WIF provider resource name does not reference the expected provider ID."
  }
}
terraform test
terraform test -verbose -filter=tests/apis.tftest.hcl

B. Terratest (Go, real integration)

// tests/integration_test.go
package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
)

func TestPipelineInfrastructure(t *testing.T) {
	t.Parallel()

	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../environments/dev",
	})

	defer terraform.Destroy(t, terraformOptions)
	terraform.InitAndApply(t, terraformOptions)

	bucketName := terraform.Output(t, terraformOptions, "bucket_name")
	assert.Contains(t, bucketName, "landing")
}
cd tests
go mod init pipeline/tests
go get github.com/gruntwork-io/terratest/modules/terraform
go test -v -timeout 30m

C. Static analysis

tflint --init && tflint --recursive
checkov -d . --framework terraform
infracost breakdown --path environments/dev

16. CI/CD with GitHub Actions via WIF

# .github/workflows/terraform.yml
name: terraform

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # required so GitHub will mint the OIDC token WIF consumes

jobs:
  plan-and-test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: environments/dev
    steps:
      - uses: actions/checkout@v4

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}   # module.iam.workload_identity_provider output
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}       # module.iam.pipeline_runner_email output

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"

      - run: terraform init
      - run: terraform fmt -check -recursive
      - run: terraform validate
      - run: terraform test
      - run: terraform plan -var-file=terraform.tfvars -out=tfplan

      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: environments/dev/tfplan

  apply:
    needs: plan-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: environments/dev
    steps:
      - uses: actions/checkout@v4
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - uses: actions/download-artifact@v4
        with:
          name: tfplan
          path: environments/dev
      - run: terraform apply tfplan

Notice there is no GOOGLE_APPLICATION_CREDENTIALS secret anywhere in this file — workload_identity_provider and service_account are the only two values google-github-actions/auth needs, and both are plain resource identifiers, not secrets that grant access on their own; the actual trust decision lives in the attribute_condition and the workloadIdentityUser binding back in the iam module.

17. Common Pitfalls & Debugging Notes

  • API enablement propagation delay: add explicit depends_on at the module level (as shown above), and if you still hit transient 403s, a short time_sleep after the apis module is a pragmatic fix.
  • WIF "permission denied" with no clear cause: check all three APIs (iam, iamcredentials, sts) are enabled before assuming the pool/provider config is wrong.
  • Attribute condition too strict or too loose: test it against a real token claim set before relying on it — a typo in assertion.repository either locks out your own CI or, worse, silently accepts more repos than intended.
  • IAM eventual consistency: a freshly created service account or binding can take a few seconds to propagate; chain depends_on explicitly rather than relying on implicit ordering.
  • GCS-to-Pub/Sub publisher identity: it's Google's gs-project-accounts service agent that needs roles/pubsub.publisher, not your own service account — a common source of confusion in the iam module.
  • State bucket must exist before terraform init: bootstrap it with gsutil mb before the GCS backend can use it.
  • disable_on_destroy: leave it false in shared projects — this bites people on their first teardown.

18. Cleanup

cd environments/dev
terraform destroy -var-file=terraform.tfvars

19. Conclusion

Splitting the pipeline into modules isn't just tidiness — it makes the trust boundaries visible: apis establishes what's turned on, iam establishes who's allowed to act as what (including the full WIF chain from GitHub's token issuer down to a specific service account), and every other module simply consumes those identities as inputs. Once the three IAM layers — API-to-API service agents, the application's own runtime identity, and Workload Identity Federation for CI — are separated this cleanly, most of the "mysterious 403" debugging disappears.

Found this useful? I write about GCP, MLOps, and recommender systems engineering regularly — check my other posts and projects on GitHub and Kaggle.

Comments

Popular posts from this blog

Automate Blog Content Creation with n8n and Grok 3 API

LangGraph Tutorial: Understanding Concepts, Functionalities, and Project Implementation

DAX: The Complete Guide