Terraform Tutorial on Google Cloud: 13 Steps, 70 Min [2026]

OpenTofu 1.13 landed in late September 2026 with official Windows ARM64 packages, new functions for taming unknown values, and an experimental linter, according to the Linuxiac release write-up. HashiCorp’s Terraform CLI moved on too. The 1.17 line reportedly brought provider requirements that accept variables and a new -minimal-refresh planning option. If you searched for a Terraform tutorial this week, you want a walkthrough that works on both binaries.

This Terraform tutorial builds a real, billable-but-tiny stack on Google Cloud in 13 steps and about 70 minutes. You will create a remote state bucket, a custom VPC, firewall rules, a service account, and a Compute Engine VM that serves a web page. Then you will run the same code with OpenTofu and compare the results. If you prefer another cloud, our Terraform tutorial for AWS covers the same ideas with Amazon’s provider.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why a Terraform Tutorial for Google Cloud in 2026

Most beginner guides still point at one cloud and one binary. That made sense in 2022. It does not match what teams run today. After HashiCorp’s license change, the community forked Terraform into OpenTofu, and by autumn 2026 both tools are actively shipping. HashiCorp itself now operates as an IBM company, and its pricing pages use the names IBM HCP Terraform and IBM Terraform Enterprise.

Google Cloud is a good teaching target for three reasons. Its project model gives you a clean blast radius, since you can delete one project and everything inside it disappears. Its Terraform provider just reached a major milestone, with HashiCorp announcing that the Terraform provider for Google Cloud 8.0 is generally available. And its state backend, a plain Cloud Storage bucket, needs no extra database or lock table.

Paul Hinze, who was identified as senior director of engineering for Terraform at HashiCorp when he spoke with Page It to the Limit, summed up the practical value this way: “Any time you’re clicking more than five times in a UI you’re attempting to get some job done. And, if that job is modeling an ideaâ€Ķand there is an API addressable layer there, that is an opportunity for Terraform to consume that API and allow you to just say directly in a declarative configuration, this is what I want.”

That rule of thumb fits Google Cloud well. Creating a VPC, two firewall rules, a service account, and a VM by hand takes dozens of console clicks. In code, it is a short set of files you can review in a pull request.

If you are still weighing tools before committing, read our breakdown of OpenTofu vs Terraform vs Pulumi. If you are still choosing a cloud, the AWS vs Google Cloud comparison lays out the trade-offs. Otherwise, keep going.

Terraform vs OpenTofu: Which Binary Should You Run

Both tools read the same HCL files, talk to the same providers, and write the same state format for the features used in this guide. The differences show up at the edges. The table below lists the ones that matter for this project.

AreaTerraform (HashiCorp, IBM)OpenTofu 1.13
Command nameterraformtofu
GovernanceOwned by HashiCorp, an IBM companyOpen source, community project
Security supportPer HashiCorp release policy1.13 series through August 2027
Built-in lintingNot part of the CLIExperimental, via -lint=...
Windows ARM64Check current release notesOfficial packages since 1.13
WinRM provisionerCheck current release notesRemoved in 1.13
macOS requirementCheck current release notesmacOS 13 Ventura or later
Managed cloud serviceHCP TerraformNone from the project itself

The takeaway: pick Terraform if your company already pays for HCP Terraform or Terraform Enterprise. Pick OpenTofu if you want a community-governed binary with a longer, clearly stated support window. This guide works on either. Where commands differ, you will see both. If your real question is about configuration management instead of provisioning, our Terraform vs Ansible comparison explains where each tool fits.

One compatibility trick keeps both binaries happy. Set required_version = ">= 1.10.0" in your configuration. OpenTofu reports its own 1.x version number, so the constraint passes on OpenTofu 1.13 and on Terraform 1.10 or newer. You will see it in Step 6.

What HCP Terraform Costs and Where IBM Fits

You do not need a paid account for this tutorial. The state lives in your own Cloud Storage bucket. Still, many readers ask whether to move to the managed service, so here is the current picture.

HashiCorp’s cloud service is called HCP Terraform, and the self-managed product is Terraform Enterprise. According to the official pricing page, Standard starts at $0.47 per resource per month and Premium starts at $0.99 per resource per month. The page lists hourly equivalents of $0.00064 and $0.00135. The Essentials tier is reportedly priced from $0.10 per resource per month, and the Free tier reportedly covers up to 500 managed resources. Pricing changes, so confirm on the vendor page before you budget.

A Qovery analysis from October 2026 notes that HashiCorp now sits inside IBM, which matters if your procurement team cares about vendor ownership. For this guide, the practical point is simple. A GCS bucket with versioning gives you remote state and locking for pennies, and you can move to a managed platform later without rewriting your code.

Prerequisites and Versions

Set aside 70 minutes and a Google account with billing enabled. The stack in this guide uses one small VM, one reserved external IP, and a few storage objects. Delete everything with one command in Step 13 to stop charges. Check Google’s current free-tier and pricing pages before you start, because they change.

RequirementVersion or settingWhy you need it
Terraform CLI1.10 or newerRuns the configuration
OpenTofu CLI1.13Optional second binary for Step 12
Google Cloud CLI (gcloud)Latest releaseLogin, project setup, bucket bootstrap
Google provider~> 8.0Talks to the Google Cloud APIs
Operating systemLinux, macOS 13 or later, or WindowsOpenTofu 1.13 requires macOS 13 Ventura or later
GitAny current 2.xVersion control and the optional CI job in Advanced Tips
Billing accountActiveCompute Engine needs billing enabled

OpenTofu 1.13 also began phasing out official 32-bit builds, so use a 64-bit machine. Confirm your tools with these commands:

terraform version
tofu version
gcloud version
git --version

You should see Terraform 1.10 or newer and OpenTofu 1.13.x. If you only plan to use one binary, skip the other.

Steps 1 to 4: Prepare the Workspace, Project, and State Bucket

The first four steps use no Terraform at all. They create the guard rails that Terraform will rely on: a folder, a cloud project, enabled APIs, and a bucket for remote state. Skipping them is the most common reason a first terraform apply fails.

Step 1: Create the project folder and Git repository

Keep infrastructure code in its own repository. Small, boring layouts age best, so use one root configuration and a modules folder.

mkdir -p tf-gcp-demo/modules/network tf-gcp-demo/modules/web
cd tf-gcp-demo
git init
printf '.terraform/\n*.tfstate\n*.tfstate.backup\n*.tfvars\n!example.tfvars\ncrash.log\n' > .gitignore

The .gitignore matters more than it looks. State files can contain secrets in plain text, and local .tfvars files often hold project IDs and tokens. Ignore both from the first commit.

Step 2: Authenticate and create a Google Cloud project

Log in with the Google Cloud CLI, then create a throwaway project. Using a dedicated project means Step 13 can end with a single delete command.

gcloud auth login
gcloud auth application-default login
export PROJECT_ID="tf-tutorial-$RANDOM"
gcloud projects create "$PROJECT_ID"
gcloud billing accounts list
gcloud billing projects link "$PROJECT_ID" --billing-account=YOUR_BILLING_ID
gcloud config set project "$PROJECT_ID"

Two logins run here for a reason. The first lets gcloud act as you. The second writes Application Default Credentials, which the Terraform Google provider reads automatically. Forgetting the second login is pitfall number one, and it produces the “could not find default credentials” error covered in the troubleshooting list.

Expected output after the project step:

Create in progress for [https://cloudresourcemanager.googleapis.com/v1/projects/tf-tutorial-18342].
Waiting for [operations/cp.1234567890] to finish...done.
Enabling service [cloudapis.googleapis.com] on project [tf-tutorial-18342]...
Operation "operations/acat.p2-123456789-abcd" finished successfully.

Step 3: Enable the APIs Terraform will call

A new project has almost every API switched off. Terraform will not enable them for you in this layout, so do it up front.

gcloud services enable \
  compute.googleapis.com \
  storage.googleapis.com \
  iam.googleapis.com \
  cloudresourcemanager.googleapis.com \
  iap.googleapis.com

Allow a minute or two for the Compute Engine API in particular. If you run terraform plan immediately and see “API has not been used in project”, wait and retry. The IAP API supports the SSH rule you will add in Step 7, so you never need to open port 22 to the internet.

Step 4: Create a versioned bucket for remote state

Local state works for a demo, but it breaks the moment a second person or a CI job touches the stack. Remote state fixes that. You create this one bucket by hand because Terraform cannot store its state in a bucket it has not created yet.

export STATE_BUCKET="${PROJECT_ID}-tfstate"
gcloud storage buckets create "gs://${STATE_BUCKET}" \
  --location=us-central1 \
  --uniform-bucket-level-access
gcloud storage buckets update "gs://${STATE_BUCKET}" --versioning

Versioning is your undo button. If a bad apply corrupts the state file, you can restore an earlier generation from the bucket. Paul Hinze’s point about declarative configuration applies here too: the state file is the memory of that declaration, and losing it is far more painful than losing a VM.

Mitchell Hashimoto, the co-creator of Terraform, explained the core idea on the Screaming in the Cloud podcast in plain terms: “So you describe servers, switches, DNS records, anything you would imagine.” The bucket you just created is the one thing in this project that you describe by hand instead of in code. Everything after it lives in HCL.

Check the bucket:

gcloud storage buckets describe "gs://${STATE_BUCKET}" --format="value(versioning.enabled,location)"

You should see True and US-CENTRAL1.

Steps 5 and 6: Variables, Versions, and the Backend

With the cloud side ready, write the first Terraform files. Keep the pinning strict and the variables typed. Both choices pay off in Step 12, when a second binary reads the same code.

Step 5: Define typed variables with validation

Create variables.tf in the root of tf-gcp-demo:

variable "project_id" {
  type        = string
  description = "Google Cloud project ID that holds all resources."
}

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

variable "zone" {
  type    = string
  default = "us-central1-a"
}

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

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "machine_type" {
  type    = string
  default = "e2-micro"
}

variable "allowed_http_cidrs" {
  type        = list(string)
  description = "CIDR ranges allowed to reach port 80."
  default     = ["0.0.0.0/0"]
}

Then add a local values file called dev.tfvars (it is ignored by Git, which is why example.tfvars is the one you commit):

project_id  = "tf-tutorial-18342"
environment = "dev"

Swap in your real project ID. The validation block rejects a typo such as production at plan time, long before any API call.

Step 6: Pin versions and configure the GCS backend

Create versions.tf. This is where the compatibility constraint from earlier lives.

terraform {
  required_version = ">= 1.10.0"

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

  backend "gcs" {}
}

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

The backend block is empty on purpose. Terraform does not allow variables inside backend settings, so you supply the values at init time through a partial configuration file named backend.hcl:

bucket = "tf-tutorial-18342-tfstate"
prefix = "tf-gcp-demo/dev"

Initialize with whichever binary you prefer:

terraform init -backend-config=backend.hcl

Expected output:

Initializing the backend...

Successfully configured the backend "gcs"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Finding hashicorp/google versions matching "~> 8.0"...
- Installing hashicorp/google v8.x.x...

Terraform has been successfully initialized!

Commit the generated .terraform.lock.hcl file. It records provider checksums, and committing it keeps teammates and CI on identical provider builds. If you plan to use Windows, macOS, and Linux machines side by side, run terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64 so the lock file covers all three.

Steps 7 to 9: Build the Network, the VM, and the Root Module

Now the interesting part. You will split the stack into two small modules so each piece has one job. The network module owns the VPC and firewalls. The web module owns the service account, the reserved IP, and the VM. The root configuration wires them together.

Modules also teach the right habit early. When you later need a staging copy, you call the same modules with different inputs instead of copying files. If you want a second angle on modules and remote state, our longer 12-step AWS tutorial with modules and remote state covers the same pattern on Amazon’s side.

Step 7: Write the network module

Create modules/network/variables.tf:

variable "name" {
  type = string
}

variable "region" {
  type = string
}

variable "allowed_http_cidrs" {
  type = list(string)
}

Create modules/network/main.tf:

resource "google_compute_network" "this" {
  name                    = "${var.name}-vpc"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "this" {
  name                     = "${var.name}-subnet"
  region                   = var.region
  network                  = google_compute_network.this.id
  ip_cidr_range            = "10.10.0.0/24"
  private_ip_google_access = true
}

resource "google_compute_firewall" "http" {
  name          = "${var.name}-allow-http"
  network       = google_compute_network.this.name
  direction     = "INGRESS"
  source_ranges = var.allowed_http_cidrs
  target_tags   = ["web"]

  allow {
    protocol = "tcp"
    ports    = ["80"]
  }
}

resource "google_compute_firewall" "iap_ssh" {
  name          = "${var.name}-allow-iap-ssh"
  network       = google_compute_network.this.name
  direction     = "INGRESS"
  source_ranges = ["35.235.240.0/20"]
  target_tags   = ["web"]

  allow {
    protocol = "tcp"
    ports    = ["22"]
  }
}

Create modules/network/outputs.tf:

output "subnet_id" {
  value = google_compute_subnetwork.this.id
}

output "network_name" {
  value = google_compute_network.this.name
}

Three design choices deserve a sentence each. The VPC uses custom subnets because the default auto-mode network creates a subnet in every region, which is noisy and hard to audit. The HTTP rule targets a network tag instead of the whole network, so only VMs tagged web receive traffic. And the SSH rule allows only the IAP address range, which lets you connect through gcloud compute ssh --tunnel-through-iap without exposing port 22 publicly.

Step 8: Write the web module

Create modules/web/variables.tf:

variable "name" {
  type = string
}

variable "project_id" {
  type = string
}

variable "zone" {
  type = string
}

variable "machine_type" {
  type = string
}

variable "subnet_id" {
  type = string
}

variable "environment" {
  type = string
}

variable "labels" {
  type    = map(string)
  default = {}
}

Create modules/web/main.tf:

resource "google_service_account" "vm" {
  account_id   = "${var.name}-vm"
  display_name = "Runtime identity for ${var.name}"
}

resource "google_project_iam_member" "log_writer" {
  project = var.project_id
  role    = "roles/logging.logWriter"
  member  = "serviceAccount:${google_service_account.vm.email}"
}

resource "google_compute_address" "this" {
  name = "${var.name}-ip"
}

resource "google_compute_instance" "this" {
  name         = var.name
  zone         = var.zone
  machine_type = var.machine_type
  tags         = ["web"]
  labels       = var.labels

  allow_stopping_for_update = true

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-12"
      size  = 10
    }
  }

  network_interface {
    subnetwork = var.subnet_id

    access_config {
      nat_ip = google_compute_address.this.address
    }
  }

  service_account {
    email  = google_service_account.vm.email
    scopes = ["logging-write", "monitoring-write"]
  }

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

  metadata_startup_script = <<-EOT
    #!/bin/bash
    set -eu
    apt-get update -y
    apt-get install -y nginx
    echo "<h1>Hello from ${var.name}</h1><p>Environment: ${var.environment}</p>" > /var/www/html/index.html
    systemctl enable --now nginx
  EOT

  depends_on = [google_project_iam_member.log_writer]
}

Create modules/web/outputs.tf:

output "external_ip" {
  value = google_compute_address.this.address
}

output "instance_name" {
  value = google_compute_instance.this.name
}

The service account is the quiet hero of this file. Without it, the VM would run as the default Compute Engine account, which in many organizations carries broad project permissions. A dedicated account with one logging role keeps the blast radius small if the web server is ever compromised. Secure boot, the virtual TPM, and integrity monitoring cost nothing extra and harden the boot chain.

Step 9: Wire everything together in the root module

Create main.tf at the root:

locals {
  name = "tfdemo-${var.environment}"

  labels = {
    environment = var.environment
    managed-by  = "iac"
    tutorial    = "tf-gcp-demo"
  }
}

module "network" {
  source             = "./modules/network"
  name               = local.name
  region             = var.region
  allowed_http_cidrs = var.allowed_http_cidrs
}

module "web" {
  source       = "./modules/web"
  name         = local.name
  project_id   = var.project_id
  zone         = var.zone
  machine_type = var.machine_type
  subnet_id    = module.network.subnet_id
  environment  = var.environment
  labels       = local.labels
}

Create outputs.tf:

output "web_url" {
  value = "http://${module.web.external_ip}"
}

output "ssh_command" {
  value = "gcloud compute ssh ${module.web.instance_name} --zone ${var.zone} --tunnel-through-iap"
}

Your folder should now look like this:

tf-gcp-demo/
  .gitignore
  backend.hcl
  dev.tfvars
  main.tf
  outputs.tf
  variables.tf
  versions.tf
  modules/
    network/  (main.tf, variables.tf, outputs.tf)
    web/      (main.tf, variables.tf, outputs.tf)

Notice that the web module never references the network module directly. It receives subnet_id as an input, and the root passes the value through. Terraform reads that reference to build its dependency graph, so it creates the subnet before the VM without any depends_on at the root. This is the pattern that keeps modules reusable.

Steps 10 and 11: Plan, Apply, and Verify the Stack

Everything is written. Before touching the cloud, run the three cheap checks that catch most mistakes. Then plan, read the plan like a code review, and apply.

Step 10: Format, validate, and plan

terraform fmt -recursive
terraform validate
terraform plan -var-file=dev.tfvars -out=tfplan

fmt rewrites files into canonical style, validate checks syntax and types without calling any API, and plan compares your code to real infrastructure. Saving the plan with -out lets you apply exactly what you reviewed. On the 1.17 line, HashiCorp reportedly added a -minimal-refresh option that refreshes only resources with proposed changes, which can shorten plans on large stacks. Check terraform plan -help on your version before relying on it.

Expected output, trimmed:

Terraform will perform the following actions:

  # module.network.google_compute_network.this will be created
  # module.network.google_compute_subnetwork.this will be created
  # module.network.google_compute_firewall.http will be created
  # module.network.google_compute_firewall.iap_ssh will be created
  # module.web.google_service_account.vm will be created
  # module.web.google_project_iam_member.log_writer will be created
  # module.web.google_compute_address.this will be created
  # module.web.google_compute_instance.this will be created

Plan: 8 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ssh_command = "gcloud compute ssh tfdemo-dev --zone us-central1-a --tunnel-through-iap"
  + web_url     = (known after apply)

Count the lines. Eight resources, all additions, nothing destroyed. If you see destroys on a first run, stop and find out why. Reading the plan is the single best habit in this whole tutorial.

Step 11: Apply and test the web server

terraform apply tfplan

Terraform creates resources in dependency order. The VPC comes first, then the subnet and firewalls, then the service account and address, and finally the VM. Expect two to three minutes.

module.network.google_compute_network.this: Creation complete after 14s
module.network.google_compute_subnetwork.this: Creation complete after 12s
module.web.google_service_account.vm: Creation complete after 2s
module.web.google_compute_instance.this: Creation complete after 23s

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

ssh_command = "gcloud compute ssh tfdemo-dev --zone us-central1-a --tunnel-through-iap"
web_url = "http://34.xx.xx.xx"

The VM boots before nginx finishes installing, so give the startup script about a minute. Then test:

curl -s "$(terraform output -raw web_url)"

You should get:

<h1>Hello from tfdemo-dev</h1><p>Environment: dev</p>

If curl hangs, jump to the troubleshooting list. The usual suspects are a missing network tag, a startup script that has not finished, or a corporate network that blocks outbound port 80. Confirm the state landed in your bucket too:

gcloud storage ls "gs://${STATE_BUCKET}/tf-gcp-demo/dev/"

You will see a default.tfstate object. That file is now the single source of truth for the stack. If a VM is all you need for production, a managed runtime may be a better fit, and our guide to setting up Google Cloud Run shows the container route.

Step 12: Run the Same Code With OpenTofu 1.13

This is the step most Terraform tutorials skip. Your configuration already passes both tools, so the experiment takes five minutes. Do it on a state you can afford to lose, which is why versioning on the bucket matters.

First, record the current state generation so you can roll back:

gcloud storage ls --all-versions "gs://${STATE_BUCKET}/tf-gcp-demo/dev/"

Then initialize and plan with OpenTofu:

tofu init -backend-config=backend.hcl -reconfigure
tofu plan -var-file=dev.tfvars

The result you want:

No changes. Your infrastructure matches the configuration.

A clean “No changes” proves both binaries read the same state and agree about the world. If you see a diff, read it before applying anything. The most common cause is a provider version difference, which the lock file prevents when you commit it.

OpenTofu 1.13 adds a few things worth trying here. The release introduced convert and assume... functions that reduce the “known after apply” noise in plans. The DEV Community write-up on the release walks through the idea. The release also ships an experimental built-in linter, enabled with the -lint=... option on tofu validate, tofu plan, or tofu apply. Run tofu validate -help to see the rule names your build supports. Treat the linter and the new Symbol Libraries feature as experiments. Both are labeled experimental in the release notes, so do not wire them into a blocking CI gate yet.

Compare the two binaries side by side:

TaskTerraformOpenTofu
Initializeterraform init -backend-config=backend.hcltofu init -backend-config=backend.hcl
Formatterraform fmt -recursivetofu fmt -recursive
Plan to fileterraform plan -out=tfplantofu plan -out=tfplan
Apply saved planterraform apply tfplantofu apply tfplan
Read an outputterraform output -raw web_urltofu output -raw web_url
Destroyterraform destroytofu destroy

One warning on switching back and forth. Treat the choice as a team decision, not a daily toggle. State written by a newer binary can become unreadable to an older one, so pin one tool per environment and record it in your README.

Step 13: Destroy Everything and Stop the Meter

Cleanup is part of the tutorial. Run destroy first so Terraform removes resources in reverse dependency order, then delete the bootstrap pieces you created by hand.

terraform destroy -var-file=dev.tfvars
gcloud storage rm -r "gs://${STATE_BUCKET}"
gcloud projects delete "$PROJECT_ID"

Expected closing line from the first command:

Destroy complete! Resources: 8 destroyed.

Deleting the project schedules it for removal and stops billing for resources inside it. Google keeps deleted projects recoverable for a short window, so confirm the current retention period in its documentation if you need to restore one. If you plan to keep the project for experiments, at least stop the VM or run destroy so the reserved external IP does not sit idle and billed.

8 Common Terraform Pitfalls That Cost Beginners an Afternoon

These mistakes show up again and again in first projects. Each one is cheap to avoid once you know it exists.

  1. Skipping Application Default Credentials. Logging in with gcloud auth login only authenticates the CLI. The Google provider reads a separate credential file. Run gcloud auth application-default login too, or Terraform fails before it plans anything.
  2. Committing state or variable files. State can hold passwords, private keys, and tokens in plain text. Ignore *.tfstate and *.tfvars in .gitignore before your first commit, then store state in a bucket with restricted access.
  3. Leaving providers unpinned. A loose constraint lets a major provider release change your plan overnight. Use ~> 8.0, commit .terraform.lock.hcl, and upgrade on purpose with init -upgrade.
  4. Applying without reading the plan. A single unexpected “destroy” line can delete a database. Save plans with -out and review them like a pull request.
  5. Editing resources in the console. Manual changes create drift. The next plan either reverts them or fails. Treat the console as read-only for anything Terraform owns.
  6. Putting variables in the backend block. Terraform rejects this. Use a partial backend configuration file, as in Step 6.
  7. Opening SSH to the world. A 0.0.0.0/0 rule on port 22 invites constant scanning. The IAP range used in Step 7 gives you access without public exposure.
  8. Running the default Compute Engine service account. It often carries broad permissions. Create a dedicated account with one narrow role, as in Step 8.

A good test for any of these: ask what happens if a teammate clones the repository tomorrow. If the answer involves a secret on your laptop, a manual console step, or a version you did not pin, fix it today.

12 Troubleshooting Fixes for Terraform and OpenTofu on Google Cloud

Search any error string below to jump to its cause. Most failures on this stack come from credentials, APIs, or names.

  1. “could not find default credentials.” Run gcloud auth application-default login. If you run in CI, use workload identity federation instead of a downloaded key.
  2. “Compute Engine API has not been used in project” (403). Run gcloud services enable compute.googleapis.com, wait two minutes, and retry the plan.
  3. “billing account for the owning project is disabled.” Link a billing account with gcloud billing projects link. Compute Engine will not create resources without one.
  4. “storage: bucket doesn’t exist” during init. The bucket name in backend.hcl does not match the one from Step 4. Print yours with echo $STATE_BUCKET and fix the file.
  5. “Error acquiring the state lock.” Another run holds the lock, or a crashed run left one behind. Confirm nobody is applying, then run terraform force-unlock LOCK_ID using the ID from the message.
  6. “Unsupported Terraform Core version.” Your binary is older than required_version. Upgrade it, or lower the constraint only if you have tested the older release.
  7. An already-exists error on a service account or address. A previous run created the object and the state lost track of it. Use an import block (see the advanced tips) or choose a different name.
  8. curl to the external IP hangs. Check three things in order: the VM has the web tag, the firewall rule targets that tag, and the startup script finished. Read the boot log with gcloud compute instances get-serial-port-output tfdemo-dev --zone us-central1-a.
  9. “Variables may not be used here” in the backend block. Move the values into backend.hcl and pass -backend-config.
  10. “Quota ‘IN_USE_ADDRESSES’ exceeded.” The region has hit its external IP limit. Release unused addresses with gcloud compute addresses list and delete, or request more quota in the console.
  11. Provider checksum or lock-file mismatch. Someone initialized on a different platform. Run terraform providers lock for every platform your team uses, then commit the updated lock file.
  12. OpenTofu refuses to start on an older Mac. OpenTofu 1.13 requires macOS 13 Ventura or later. Update the OS or stay on the 1.12 series until you can. The 1.12 line is older than 1.13, so plan the upgrade.

If none of these match, rerun with detailed logs. Set TF_LOG=DEBUG (or TF_LOG=TRACE for the full wire trace), repeat the failing command, and read the last 50 lines. Strip tokens and project IDs before you paste logs into a forum post.

Advanced Terraform Tips for Teams

Once the basic stack works, these upgrades move it from a demo toward something you can run with colleagues.

Import existing resources with code

Instead of clicking through terraform import, declare the import in configuration so reviewers can see it:

import {
  to = module.web.google_compute_address.this
  id = "projects/tf-tutorial-18342/regions/us-central1/addresses/tfdemo-dev-ip"
}

Run a plan, confirm it shows an import and no changes, then apply. Remove the block afterward. Both Terraform and OpenTofu support the syntax.

Rename safely with moved blocks

If you rename a resource in code, Terraform treats it as a destroy and create. A moved block tells it the object is the same:

moved {
  from = module.web.google_compute_instance.this
  to   = module.web.google_compute_instance.server
}

Add native tests

Both binaries ship a built-in test runner. Create tests/network.tftest.hcl:

run "subnet_is_private" {
  command = plan

  assert {
    condition     = module.network.network_name == "tfdemo-dev-vpc"
    error_message = "Unexpected VPC name."
  }
}

Run it with terraform test or tofu test. Plan-mode tests are free, because they never create resources.

Protect production with prevent_destroy

OpenTofu 1.12 introduced dynamic prevent_destroy, so the flag can depend on other values in the same module. Teams can protect only the prod environment without duplicating code. Check your Terraform version’s release notes before using the same pattern there.

Run plans in CI without long-lived keys

Download no service account keys. Use workload identity federation so GitHub Actions exchanges a short-lived token for Google credentials:

name: plan
on: [pull_request]
permissions:
  id-token: write
  contents: read
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.WIF_SERVICE_ACCOUNT }}
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init -backend-config=backend.hcl
      - run: tofu plan -var-file=dev.tfvars -lock-timeout=60s

Pin each action to the current major tag or a commit hash, and swap in hashicorp/setup-terraform if you run Terraform. Keep the apply step manual or gated by approval.

Consider where state lives

A GCS bucket is a fine default, and the pattern holds for other object stores too. If you run a multi-cloud shop and compare storage economics, our review of Backblaze B2 vs Google Cloud vs Azure Blob covers the price side. Just confirm the backend you pick supports locking.

The Complete Working Project at a Glance

By now you have a repository that provisions a hardened web server on Google Cloud from scratch and removes it again with one command. The table maps every step to its file and a realistic time budget. The minutes add up to the 70 in the title.

StepWhat you doMain file or commandMinutes
1Create folder and Git repo.gitignore3
2Log in and create projectgcloud auth application-default login6
3Enable APIsgcloud services enable4
4Create versioned state bucketgcloud storage buckets create5
5Define typed variablesvariables.tf, dev.tfvars6
6Pin versions, set backendversions.tf, backend.hcl6
7Build the network modulemodules/network7
8Build the web modulemodules/web8
9Wire the root modulemain.tf, outputs.tf5
10Format, validate, planterraform plan -out=tfplan4
11Apply and testterraform apply tfplan6
12Repeat with OpenTofutofu plan6
13Destroy and clean upterraform destroy4

To save typing on later runs, add a small Makefile at the root. Use tabs for indentation, as Make requires:

TF ?= terraform
VARS ?= dev.tfvars

init:
	$(TF) init -backend-config=backend.hcl

check:
	$(TF) fmt -check -recursive
	$(TF) validate

plan: check
	$(TF) plan -var-file=$(VARS) -out=tfplan

apply:
	$(TF) apply tfplan

destroy:
	$(TF) destroy -var-file=$(VARS)

Now make plan runs Terraform, and make plan TF=tofu runs OpenTofu against the identical code. That one variable is the whole story of the fork for most teams in 2026: two binaries, one configuration.

If this project convinced you to move more of your estate into code, widen the lens next. Our AWS vs Azure vs Google Cloud platform comparison helps you choose where the next stack should live, and the cloud computing section collects the rest of our infrastructure guides.

FAQ: Terraform Tutorial Questions Answered

Is Terraform free to use?

The command-line tool is free to download and run, and this tutorial uses no paid HashiCorp product. HCP Terraform is the paid managed service. According to the official pricing page, Standard starts at $0.47 per resource per month and Premium at $0.99. You still pay Google Cloud for the resources Terraform creates.

Should I learn Terraform or OpenTofu first?

Learn the shared language first, since HCL, providers, state, and modules are identical. Then choose the binary by your employer’s stack or your tolerance for vendor ownership. The configuration in this tutorial runs on both.

What is the latest OpenTofu version?

OpenTofu 1.13.0 was released in late September 2026, and the 1.13 series is scheduled for security support through August 2027. Patch releases may have followed, so check the project’s release page before you install.

What is the latest Terraform version?

Terraform 1.17 is the line this article targets. HashiCorp’s own documentation is the place to confirm the current patch release. The configuration here only requires 1.10 or newer.

Do I need the Google Cloud console at all?

Only for billing setup and for checking results. Everything else in this tutorial runs from the terminal. After the first apply, treat the console as read-only to avoid drift.

Why use a GCS bucket for state instead of local files?

A bucket gives you locking, versioning, and shared access. Local state breaks as soon as a second person or a CI job joins, and it sits unprotected on one laptop.

How much will this tutorial cost on Google Cloud?

The stack uses one small VM, one reserved IP, and a small storage bucket, and you destroy it in Step 13. Exact charges depend on current Google pricing and free-tier rules, so check them before you start. Running the whole project for an hour is a small expense.

Can I use this code on AWS or Azure?

The structure carries over, but the resources do not. You would swap the Google provider and resource types for the target cloud’s equivalents. Modules, variables, state, and the plan and apply workflow stay the same.

The takeaway: write the code once, pin your versions, store state remotely, and let either binary read it. Do that and the 2026 fork becomes a choice you can reverse instead of a migration you dread.

Sofia LindstrÃķm

Sofia LindstrÃķm

Editor-in-Chief

Sofia LindstrÃķm is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles