Terraform Fundamentals
Providers - Your Connection to the Cloud
A provider is Terraform's plugin for talking to an API. GCP, AWS, Azure, Kubernetes, Cloudflare — each has a provider. You declare which ones you need, and Terraform downloads them.
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
provider "google" {
project = "my-project-id"
region = "us-central1"
}Why version pinning matters: Without version = "~> 5.0", Terraform grabs the latest provider. A major version bump can include breaking changes. Pin it.
You can use multiple providers for multi-region deployments using alias:
provider "google" {
project = "my-project-id"
region = "us-central1"
alias = "us"
}
provider "google" {
project = "my-project-id"
region = "europe-west1"
alias = "europe"
}
resource "google_storage_bucket" "logs_us" {
provider = google.us
name = "my-app-logs-us"
location = "US"
}
resource "google_storage_bucket" "logs_eu" {
provider = google.europe
name = "my-app-logs-eu"
location = "EU"
}Resources - The Core Building Block
A resource is a single piece of infrastructure: a VM, a database, a DNS record. You declare what you want, Terraform figures out how to create it.
resource "google_compute_instance" "api_server" {
name = "api-server"
machine_type = "e2-micro"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
access_config {}
}
labels = {
environment = "production"
}
}The format is always resource "type" "name". The local name is how you reference it elsewhere. Terraform builds a dependency graph from these references and infers ordering automatically.
resource "google_compute_firewall" "api_fw" {
name = "allow-https"
network = "default"
allow {
protocol = "tcp"
ports = ["443"]
}
source_ranges = ["0.0.0.0/0"]
}
resource "google_compute_instance" "api_server" {
name = "api-server"
machine_type = "e2-micro"
zone = "us-central1-a"
tags = [google_compute_firewall.api_fw.name]
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
access_config {}
}
}The firewall reference creates an implicit dependency — Terraform knows to create the firewall before the instance.
Variables - Parameterizing Your Config
Hardcoding values works for demos. Real projects use variables.
variable "environment" {
type = string
description = "Deployment environment"
default = "dev"
}
variable "machine_type" {
type = string
default = "e2-micro"
validation {
condition = contains(["e2-micro", "e2-small", "e2-medium"], var.machine_type)
error_message = "Must be e2-micro, e2-small, or e2-medium."
}
}The diagram shows the precedence order — CLI flags override everything, defaults are last resort.
Outputs expose values after apply. Locals are computed values you don't want to repeat:
output "api_server_ip" {
value = google_compute_instance.api_server.network_interface[0].access_config[0].nat_ip
description = "External IP of the API server"
}
output "db_connection" {
value = google_sql_database_instance.main.connection_name
sensitive = true
}
locals {
common_labels = {
project = "my-app"
environment = var.environment
managed_by = "terraform"
}
}State - The Most Misunderstood Part
State is where Terraform stores its understanding of what exists. It's a JSON file (terraform.tfstate) that maps your config to real infrastructure.
Why state exists: Cloud APIs don't have a “diff” endpoint. Terraform can't ask GCP “what changed?” So it keeps its own record. When you run plan, Terraform compares three things: your .tf files, the state, and the real cloud resources.
Resource IDs, attribute values, dependency graph, sensitive outputs. Everything Terraform needs to map config to reality.
Use terraform state commands instead:
terraform state list # list everything
terraform state show google_compute_instance.api_server # show details
terraform state mv old_name new_name # rename
terraform state rm google_compute_instance.legacy # remove from stateLocal vs Remote State
Local state (terraform.tfstate on your disk) is the default. It breaks immediately with teams: no sharing, no locking, no backup.
Remote state stores the file in a shared backend. This is non-negotiable for teams.
terraform {
backend "gcs" {
bucket = "my-company-terraform-state"
prefix = "prod/api"
}
}No extra database needed. When you run apply, Terraform acquires a lock. If someone else is already applying, you get a lock error.
If someone's laptop crashed mid-apply: terraform force-unlock LOCK_ID. Only do this if you're sure no operation is running.
Enable versioning on the bucket so you can roll back state. State contains secrets (DB passwords, API keys) — restrict bucket access.
Importing Existing Resources Into State
Most real projects don't start with a blank slate. Someone already clicked around in the GCP console, provisioned a Cloud SQL instance, created a bucket, or set up a VPC. Now you want to manage those resources with Terraform — without deleting and recreating them.
This is what terraform import is for. It takes an existing cloud resource and pulls it into Terraform's state file so Terraform knows it exists and owns it.
# syntax: terraform import <resource_type>.<local_name> <cloud_resource_id>
terraform import google_compute_instance.api_server projects/my-project/zones/us-central1-a/instances/api-server
terraform import google_storage_bucket.assets my-company-assets
terraform import google_sql_database_instance.main my-db-instance-nameAfter running import, the resource is in state but Terraform will complain on the next plan if there's no matching resource block in your .tf files. You must write the config yourself.
The full workflow:
# 1. Write the resource block in your .tf file (match the real config)
# 2. Import to pull it into state
terraform import google_compute_instance.api_server <resource-id>
# 3. Run plan — it should show no changes if your config matches reality
terraform plan
# 4. If plan shows diffs, adjust your .tf until plan is clean
# 5. Commit — the resource is now fully managed by TerraformGetting plan to show “no changes” is the hard part. Cloud resources have many attributes, and you need your .tf config to match what's actually deployed. Use terraform state show to inspect what got imported:
terraform state show google_compute_instance.api_server
# outputs all attributes Terraform captured — use these to fill in your .tfTerraform 1.5+ import blocks: Instead of a CLI command, you can declare imports directly in HCL and apply them as part of a normal apply:
# import.tf — declare what to import
import {
id = "projects/my-project/zones/us-central1-a/instances/api-server"
to = google_compute_instance.api_server
}
# With -generate-config-out, Terraform 1.5+ can draft the resource block:
terraform plan -generate-config-out=generated.tfTerraform 1.5+ can generate a draft .tf config from the imported state. It's not production-ready (lots of defaults you may not want), but it's a good starting point to edit rather than writing from scratch.
Tools like terraformer can reverse-engineer existing GCP/AWS infrastructure into .tf files and state. Good for large migrations, but always review the output — generated configs tend to be verbose and need cleanup.
After import, treat those resources like any other — all future changes go through Terraform. The manual-click era for that resource is over.
Drift Detection
Drift happens when someone changes infrastructure outside of Terraform. A teammate resizes an instance in the GCP Console. Now reality doesn't match state.
$ terraform plan
# google_compute_instance.api_server will be updated in-place
~ resource "google_compute_instance" "api_server" {
~ machine_type = "e2-standard-2" -> "e2-micro"
# (was changed manually, Terraform wants to revert)
}Run terraform apply to revert the manual change. Terraform is the source of truth.
Update your .tf files to match reality, then run plan to confirm no changes.
terraform apply -refresh-only updates state to match reality without changing infrastructure.
The lesson: all changes should go through Terraform. Console clicks are the #1 source of confusing plans.
Plan vs Apply - The Mental Model
terraform plan is a dry run — what Terraform would do. terraform apply makes it real.
terraform plan # preview changes
terraform apply # execute changes
terraform plan -out=tfplan # save plan to file
terraform apply tfplan # apply saved plan exactlyThe diagram shows the four plan symbols. The scariest is -/+ (replace) — some changes force Terraform to destroy and recreate. For a Cloud SQL instance, that means data loss.
A real-world plan output:
Terraform will perform the following actions:
# google_compute_firewall.api_fw will be updated in-place
~ resource "google_compute_firewall" "api_fw" {
~ source_ranges = ["0.0.0.0/0"] -> ["10.128.0.0/9"]
}
# google_compute_instance.api_server will be replaced
-/+ resource "google_compute_instance" "api_server" {
~ boot_disk { ~ image = "debian-11" -> "debian-12" }
~ machine_type = "e2-micro" -> "e2-small"
}
Plan: 1 to add, 1 to change, 1 to destroy.Always read the plan. In CI: PR opens → plan runs → output posted as PR comment → team reviews → merge triggers apply.
Idempotency - Apply as Many Times as You Want
Terraform is idempotent. Running apply twice with the same config produces the same result. The second run does nothing.
$ terraform apply
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
$ terraform apply
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.Compare this to imperative commands like gcloud compute instances create which would create a second VM on re-run.
When idempotency breaks: Some resources have attributes that change on every apply. Use lifecycle to handle it:
resource "google_compute_instance" "api_server" {
name = "api-server"
machine_type = "e2-micro"
zone = "us-central1-a"
boot_disk {
initialize_params { image = "debian-cloud/debian-12" }
}
network_interface { network = "default" }
lifecycle {
ignore_changes = [metadata["startup-script"]]
}
}Putting It Together
Here's how these concepts come together in a production codebase. Each environment has its own state file, backend config, and variable values. Modules are shared.
# environments/prod/main.tf
module "api" {
source = "../../modules/api"
project_id = var.project_id
environment = "prod"
machine_type = "e2-standard-2"
min_instances = 3
}
module "database" {
source = "../../modules/database"
project_id = var.project_id
environment = "prod"
tier = "db-custom-4-15360"
ha_enabled = true
}