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.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
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.
| Area | Terraform (HashiCorp, IBM) | OpenTofu 1.13 |
|---|---|---|
| Command name | terraform | tofu |
| Governance | Owned by HashiCorp, an IBM company | Open source, community project |
| Security support | Per HashiCorp release policy | 1.13 series through August 2027 |
| Built-in linting | Not part of the CLI | Experimental, via -lint=... |
| Windows ARM64 | Check current release notes | Official packages since 1.13 |
| WinRM provisioner | Check current release notes | Removed in 1.13 |
| macOS requirement | Check current release notes | macOS 13 Ventura or later |
| Managed cloud service | HCP Terraform | None 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.
| Requirement | Version or setting | Why you need it |
|---|---|---|
| Terraform CLI | 1.10 or newer | Runs the configuration |
| OpenTofu CLI | 1.13 | Optional second binary for Step 12 |
Google Cloud CLI (gcloud) | Latest release | Login, project setup, bucket bootstrap |
| Google provider | ~> 8.0 | Talks to the Google Cloud APIs |
| Operating system | Linux, macOS 13 or later, or Windows | OpenTofu 1.13 requires macOS 13 Ventura or later |
| Git | Any current 2.x | Version control and the optional CI job in Advanced Tips |
| Billing account | Active | Compute 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:
| Task | Terraform | OpenTofu |
|---|---|---|
| Initialize | terraform init -backend-config=backend.hcl | tofu init -backend-config=backend.hcl |
| Format | terraform fmt -recursive | tofu fmt -recursive |
| Plan to file | terraform plan -out=tfplan | tofu plan -out=tfplan |
| Apply saved plan | terraform apply tfplan | tofu apply tfplan |
| Read an output | terraform output -raw web_url | tofu output -raw web_url |
| Destroy | terraform destroy | tofu 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.
- Skipping Application Default Credentials. Logging in with
gcloud auth loginonly authenticates the CLI. The Google provider reads a separate credential file. Rungcloud auth application-default logintoo, or Terraform fails before it plans anything. - Committing state or variable files. State can hold passwords, private keys, and tokens in plain text. Ignore
*.tfstateand*.tfvarsin.gitignorebefore your first commit, then store state in a bucket with restricted access. - 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 withinit -upgrade. - Applying without reading the plan. A single unexpected “destroy” line can delete a database. Save plans with
-outand review them like a pull request. - 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.
- Putting variables in the backend block. Terraform rejects this. Use a partial backend configuration file, as in Step 6.
- Opening SSH to the world. A
0.0.0.0/0rule on port 22 invites constant scanning. The IAP range used in Step 7 gives you access without public exposure. - 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.
- “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. - “Compute Engine API has not been used in project” (403). Run
gcloud services enable compute.googleapis.com, wait two minutes, and retry the plan. - “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. - “storage: bucket doesn’t exist” during init. The bucket name in
backend.hcldoes not match the one from Step 4. Print yours withecho $STATE_BUCKETand fix the file. - “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_IDusing the ID from the message. - “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. - 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
importblock (see the advanced tips) or choose a different name. - curl to the external IP hangs. Check three things in order: the VM has the
webtag, the firewall rule targets that tag, and the startup script finished. Read the boot log withgcloud compute instances get-serial-port-output tfdemo-dev --zone us-central1-a. - “Variables may not be used here” in the backend block. Move the values into
backend.hcland pass-backend-config. - “Quota ‘IN_USE_ADDRESSES’ exceeded.” The region has hit its external IP limit. Release unused addresses with
gcloud compute addresses listanddelete, or request more quota in the console. - Provider checksum or lock-file mismatch. Someone initialized on a different platform. Run
terraform providers lockfor every platform your team uses, then commit the updated lock file. - 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.
| Step | What you do | Main file or command | Minutes |
|---|---|---|---|
| 1 | Create folder and Git repo | .gitignore | 3 |
| 2 | Log in and create project | gcloud auth application-default login | 6 |
| 3 | Enable APIs | gcloud services enable | 4 |
| 4 | Create versioned state bucket | gcloud storage buckets create | 5 |
| 5 | Define typed variables | variables.tf, dev.tfvars | 6 |
| 6 | Pin versions, set backend | versions.tf, backend.hcl | 6 |
| 7 | Build the network module | modules/network | 7 |
| 8 | Build the web module | modules/web | 8 |
| 9 | Wire the root module | main.tf, outputs.tf | 5 |
| 10 | Format, validate, plan | terraform plan -out=tfplan | 4 |
| 11 | Apply and test | terraform apply tfplan | 6 |
| 12 | Repeat with OpenTofu | tofu plan | 6 |
| 13 | Destroy and clean up | terraform destroy | 4 |
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.





