Terraform on GCP: State, Modules, and the Patterns That Actually Scale
Terraform has become the practical default for provisioning GCP infrastructure, including among teams running entirely on Google Cloud who could use the native Deployment Manager instead โ a signal worth taking seriously about which tool actually holds up as infrastructure complexity grows. Writing individual resource blocks is the easy part and well covered by the official documentation; the part that actually determines whether a Terraform setup stays maintainable at scale or becomes a source of dread is state management, module structure, and how you handle multiple environments โ decisions that are hard to retrofit cleanly once made poorly and lived with for a year.
This is where real, hard-won practice diverges most from a getting-started tutorial, so thatโs the focus here.
Remote State: The Decision That Matters Most Early
Terraformโs local state file (terraform.tfstate) works fine for a solo experiment and becomes a genuine liability the moment more than one person touches the same infrastructure โ two people applying changes against their own local state files independently is a recipe for drift and conflicting changes neither person can see coming.
terraform { backend "gcs" { bucket = "my-company-terraform-state" prefix = "prod/networking" }}# Create the state bucket with versioning enabled โ non-negotiablegsutil mb -l us-central1 gs://my-company-terraform-stategsutil versioning set on gs://my-company-terraform-stateStoring state in a Cloud Storage bucket, with versioning enabled, gives you both team-shared state (everyone applies against the same source of truth) and a recovery path if state ever gets corrupted or an apply goes badly wrong โ versioning means you can restore a previous state file version, which has saved real infrastructure teams from what would otherwise be a genuinely bad day. Skipping remote state, or skipping bucket versioning specifically, is one of the few Terraform decisions thatโs actively dangerous rather than just suboptimal.
State Locking: Preventing Concurrent Apply Disasters
Engineer A runs `terraform apply` โ โผGCS backend acquires a lock (via Cloud Storage'snative object locking mechanism) โEngineer B runs `terraform apply` at the same time โ โผ Lock held โ Engineer B's apply blocks until Engineer A's completesThe GCS backend handles state locking automatically โ this isnโt optional configuration, itโs built into using the GCS backend, and itโs what prevents the genuinely bad scenario of two concurrent applies racing against the same state and corrupting it. This is one of several reasons the GCS backend (over local state, even local state shared via a network drive) is the correct default for any team, not a nice-to-have for larger organizations specifically.
Module Structure: Reuse Without Chaos
modules/ โโโ gke-cluster/ โ โโโ main.tf โ โโโ variables.tf โ โโโ outputs.tf โโโ vpc-network/ โ โโโ main.tf โ โโโ variables.tf โ โโโ outputs.tf โโโ cloud-sql-instance/ โโโ main.tf โโโ variables.tf โโโ outputs.tf
environments/ โโโ dev/ โ โโโ main.tf (calls modules with dev-specific variables) โโโ staging/ โ โโโ main.tf (calls modules with staging-specific variables) โโโ prod/ โโโ main.tf (calls modules with prod-specific variables)module "networking" { source = "../../modules/vpc-network" network_name = "prod-vpc" subnet_cidr = "10.10.0.0/16"}
module "gke" { source = "../../modules/gke-cluster" cluster_name = "prod-cluster" network = module.networking.network_self_link node_count = 5}This structure โ reusable modules parameterized by variables, called from environment-specific configurations โ is what actually lets a team avoid writing the same VPC or GKE cluster configuration three times with copy-pasted, subtly-diverging HCL for dev, staging, and prod. The module encapsulates the โhow,โ the environment configuration supplies the โwhat specifically for this environment,โ and a change to the moduleโs internal logic (say, a security hardening update to the GKE module) propagates to every environment consistently once each environmentโs main.tf is re-applied.
Module outputs, like module.networking.network_self_link in the example above, are what let modules compose cleanly โ the GKE module doesnโt need to know how the VPC module constructed its network internally, only that it can reference the networkโs identifier through a stable, documented output. This composability is what makes larger Terraform codebases genuinely maintainable rather than an increasingly tangled web of resources referencing each otherโs internal implementation details directly.
Workspaces vs Separate Directories: The Multi-Environment Decision
โโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ Approach โ Trade-off โโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโคโ Terraform Workspaces โ One codebase, multiple named state โโ โ instances (`terraform workspace select โโ โ prod`) โ less code duplication, but โโ โ genuinely easy to apply against the wrong โโ โ workspace by mistake โโ Separate directories โ Explicit directory per environment (as โโ (shown above) โ in the module example) โ more files, but โโ โ you can never accidentally run `apply` โโ โ against the wrong environment, because the โโ โ environment is the directory you're in โโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโWorkspaces are appealing for their reduced duplication, but the โwhich workspace am I currently inโ ambiguity is a genuinely common source of real incidents โ an engineer runs terraform apply believing theyโre in the dev workspace, when a previous workspace select command actually left them in prod. Separate directories per environment trade some file duplication for an explicit, hard-to-mistake safety property: the environment youโre operating on is literally the directory youโre standing in, not an easily-forgotten prior commandโs side effect. For anything genuinely production-critical, most practitioners consider this trade-off worth it.
The GCP Provider: Authentication in CI/CD
provider "google" { project = var.project_id region = "us-central1"}For CI/CD pipelines applying Terraform automatically, the authentication method matters for security, not just convenience:
# Workload Identity Federation โ no long-lived key neededgcloud iam workload-identity-pools create github-pool \ --location=global \ --display-name="GitHub Actions pool"Using Workload Identity Federation to let a CI/CD pipeline (GitHub Actions, GitLab CI) authenticate to GCP without a long-lived service account JSON key stored as a pipeline secret is the current best practice โ a leaked long-lived key is a standing risk for as long as it remains valid, while federated authentication issues short-lived tokens per pipeline run with no persistent credential to leak in the first place. This is a genuinely important security upgrade that a surprising number of Terraform-on-GCP setups still havenโt made, often because the long-lived-key approach was what an early tutorial showed and nobodyโs revisited it since.
A Safe CI/CD Apply Pipeline, End to End
Putting the pieces together โ remote state, plan review, and federated authentication โ into an actual pipeline flow clarifies how they combine in practice.
The plan-as-PR-comment step is worth calling out specifically โ it turns an infrastructure change into something reviewable the same way application code is, with the actual computed diff (not just the source code diff) visible to whoeverโs approving it. A reviewer can see โthis will destroy and recreate a Cloud SQL instanceโ directly in the plan output before approving, which is a meaningfully stronger safety net than reviewing only the HCL source and trying to mentally simulate what Terraform will actually do with it.
Real-World Use Case: Onboarding a New Environment Safely
A team adding a new environment (say, a dedicated environment for a specific enterprise customer with data residency requirements) benefits directly from a well-structured module setup โ creating the new environment is largely copying an existing environmentโs main.tf, adjusting variables (region, network CIDR, sizing), and running terraform plan to review exactly what will be created before applying. Because the underlying modules are already tested and used in production for other environments, this new environment inherits the same security hardening, tagging conventions, and architecture patterns automatically, rather than requiring a from-scratch design and review process each time a new environment is needed.
Best Practices
- Always use a remote backend (GCS) with versioning enabled, from the very first environment, not retrofitted after the first multi-person incident.
- Prefer separate directories over workspaces for production-critical environments, accepting the file duplication trade-off for the safety of explicit environment selection.
- Use Workload Identity Federation for CI/CD authentication, retiring any long-lived service account keys currently used for Terraform automation.
- Run
terraform planand require human review before everyapplyagainst production, treating infrastructure changes with the same rigor as application code deployments โ a plan output is your best defense against an unintended change slipping through.
Common Mistakes
Using local state, or state shared via a network drive, for anything beyond a solo experiment. This is consistently the root cause of the worst Terraform incidents โ corrupted or conflicting state with no clean recovery path.
Applying changes without reviewing the plan output carefully. A plan showing an unexpected resource replacement (not just an update) is a critical signal worth stopping and investigating before applying โ Terraform replacing a resource often means downtime or data loss for that resource, not a routine change.
Copy-pasting configuration across environments instead of using modules. This guarantees the environments drift apart silently over time as each copy gets modified independently, defeating the entire โconsistent infrastructure across environmentsโ value proposition Terraform is meant to provide.
Frequently Asked Questions
Is Terraform the same across every cloud provider, or does GCP need different knowledge? The core Terraform workflow (plan, apply, state, modules) is identical across providers โ what differs is the provider-specific resource types and their arguments, which for GCP means learning the google providerโs resource schema specifically.
Can I import existing, manually-created GCP resources into Terraform management? Yes, via terraform import, which brings an existing resource under Terraformโs state tracking without recreating it โ a common and necessary step when adopting Terraform for infrastructure that already exists.
How often should Terraform modules be updated or reviewed? Treat them like any shared library code โ version them explicitly (using Git tags or a module registry), and review changes with the understanding that a module update potentially affects every environment consuming it, not just the one being actively worked on.
Does using Terraform mean giving up the GCP Console for making changes? Not entirely, but manual console changes to Terraform-managed resources cause state drift โ the console remains useful for inspection and read-only investigation, but changes to Terraform-managed infrastructure should go through Terraform to keep state accurate.
Whatโs the risk of running terraform apply locally instead of through CI/CD? Local applies bypass the review step a PR-based pipeline enforces, and depending on local authentication setup, may use a long-lived credential rather than short-lived federated tokens โ reserving local applies for genuine emergencies, with routine changes going through the reviewed pipeline, is the safer default.
Summary
The Terraform features that get the most tutorial attention โ resource syntax, basic provider configuration โ are genuinely the easy part. What actually determines whether a GCP Terraform setup remains trustworthy as it grows is remote state with versioning from day one, a module structure that avoids environment drift, a deliberate (and safety-conscious) choice between workspaces and separate directories, and modern, keyless CI/CD authentication. Getting these foundational decisions right early is meaningfully cheaper than retrofitting them onto a year of accumulated infrastructure and institutional habits built around a riskier initial setup.