dmai/blog

dmai/blog

Overview
Terraform Fundamentals
Back to Articles
Cloud & Infrastructure

Terraform Fundamentals

Mar 23, 2026·14 min read
⚙️Terraform☁️google🚢kubernetes🌐cloudflare🖥️Compute🗄️Cloud SQLversion = "~> 5.0"Pin versions to avoid breaking changes

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.

HCL
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:

HCL
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.

HCL
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.

HCL
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.

HCL
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:

HCL
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.

What's in state:

Resource IDs, attribute values, dependency graph, sensitive outputs. Everything Terraform needs to map config to reality.

Never edit manually.

Use terraform state commands instead:

Bash
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 state

Local 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.

HCL
terraform {
  backend "gcs" {
    bucket = "my-company-terraform-state"
    prefix = "prod/api"
  }
}
GCS has built-in state locking

No extra database needed. When you run apply, Terraform acquires a lock. If someone else is already applying, you get a lock error.

Stuck lock?

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.

Bash
# 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-name
Import does NOT generate config.

After 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:

Bash
# 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 Terraform

Getting 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:

Bash
terraform state show google_compute_instance.api_server
# outputs all attributes Terraform captured — use these to fill in your .tf

Terraform 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:

HCL
# 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.tf
-generate-config-out

Terraform 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.

Bulk importing a whole project?

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.

Bash
$ 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)
  }
Option A: Revert

Run terraform apply to revert the manual change. Terraform is the source of truth.

Option B: Accept

Update your .tf files to match reality, then run plan to confirm no changes.

Option C: Refresh

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.

Bash
terraform plan                  # preview changes
terraform apply                 # execute changes
terraform plan -out=tfplan      # save plan to file
terraform apply tfplan          # apply saved plan exactly

The 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 Plan
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.

Bash
$ 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:

HCL
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.

HCL
# 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
}
Key Takeaways
→Pin provider versions. Surprise upgrades break CI.
→Use remote state from day one. Local state is a trap.
→Always read the plan. Watch -/+ on stateful resources.
→State is sacred. Never edit manually.
→All changes through Terraform. Console clicks cause drift.
→Terraform is declarative and idempotent. Trust the diff.

© 2026 dmai/blog Engineer Notes. All rights reserved.