Skip to content

HCL, Providers, and Resources

Every piece of Terraform configuration is a block written in HCL — terraform, provider, resource, and data are the four you will write constantly, and together they tell Terraform what plugins to load, how to talk to GCP, what to create, and what to read.

HCL (HashiCorp Configuration Language) is built from blocks. The general shape is a block type, zero or more quoted labels, and a body of arguments in braces:

block_type "label1" "label2" {
argument = value
}

A resource block, for instance, takes two labels; a provider block takes one. Comments use # or // for a single line, and /* */ for a multi-line block. Strings support interpolation with ${...}, though referencing another value directly (name = google_storage_bucket.reports.name) does not need the ${} wrapper — that syntax is only needed inside a larger string.

# A single-line comment.
// Also a single-line comment.
resource "google_storage_bucket" "reports" {
name = "acme-monthly-reports-${var.environment}" # interpolation inside a string
}

The terraform block configures Terraform itself: which version of Terraform is allowed to run this configuration, and which providers it needs — including where each provider comes from and which versions are acceptable.

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

source is the provider’s registry address (hashicorp/google resolves to the official Google Cloud provider on the Terraform Registry), and version is a constraint, not a pin — ~> 8.0 allows any 8.x release.

The provider block then configures one instance of a provider you declared above — for GCP, this is where the project and region (and, in real projects, things like credentials) live:

provider "google" {
project = "acme-app"
region = "us-central1"
}

resource blocks: the two labels that matter

Section titled “resource blocks: the two labels that matter”

A resource block is how you tell Terraform “this thing should exist.” It always has two labels:

resource "google_compute_instance" "web" {
name = "web-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}
  • The first label, google_compute_instance, is the resource type — defined by the provider, and it determines which arguments are valid and which GCP API Terraform calls.
  • The second label, web, is a name you choose — it only has meaning inside this configuration.

Together they form the resource’s address, google_compute_instance.web, which is how you reference this resource’s attributes anywhere else in your configuration — for example, google_compute_instance.web.id or google_compute_instance.web.self_link.

A data block reads information about something that already exists — infrastructure this configuration does not manage and will never create, update, or destroy. It is Terraform’s read-only lookup.

data "google_compute_image" "debian" {
family = "debian-12"
project = "debian-cloud"
}
resource "google_compute_instance" "web" {
name = "web-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = data.google_compute_image.debian.self_link
}
}
network_interface {
network = "default"
}
}

data.google_compute_image.debian.self_link reads exactly like a resource address, with data. in front — the type and name labels work the same way, but nothing about a data block ever shows up as a create, update, or destroy in a plan. It only ever reads.

Terraform does not apply your configuration top to bottom the way it is written. Instead, it builds a DAG (directed acyclic graph) of every resource and data source, and uses it to work out the correct order — and which resources are independent enough to be created in parallel.

Edges in that graph come from two places:

  • Implicit dependencies — whenever one resource’s argument references another resource’s attribute (like google_compute_subnetwork.app referencing google_compute_network.vpc.id, or an instance referencing a subnetwork’s ID), Terraform infers that the referenced resource must exist first.
  • Explicit dependencies — depends_on on a resource, used when one resource genuinely depends on another but that relationship is not visible through any attribute reference (for example, an IAM binding that must exist before an application starts relying on it, with no direct argument linking the two).
resource "google_compute_network" "vpc" {
name = "app-network"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "app" {
name = "app-subnet"
ip_cidr_range = "10.0.1.0/24"
region = "us-central1"
network = google_compute_network.vpc.id # implicit dependency on google_compute_network.vpc
}
resource "google_compute_instance" "web" {
name = "web-vm"
machine_type = "e2-medium"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = data.google_compute_image.debian.self_link
}
}
network_interface {
subnetwork = google_compute_subnetwork.app.id # implicit dependency on google_compute_subnetwork.app
}
}
flowchart LR
  network["google_compute_network.vpc"] --> subnet["google_compute_subnetwork.app"] --> instance["google_compute_instance.web"]
  bucket["google_storage_bucket.logs (unrelated, applies in parallel)"]
Terraform builds a DAG from references, applying independent branches in parallel

The lifecycle block: overriding Terraform’s default behavior

Section titled “The lifecycle block: overriding Terraform’s default behavior”

Every resource accepts an optional lifecycle block that changes how Terraform plans changes to that specific resource, independent of anything else in the configuration. Four arguments cover almost every real-world need.

prevent_destroy turns a planned destroy into a hard error instead of letting it proceed — a safety rail for a resource that must never be accidentally deleted:

resource "local_file" "protected" {
filename = "${path.module}/protected.txt"
content = var.content
lifecycle {
prevent_destroy = true
}
}

Real error from terraform plan -destroy against the exact block above:

$ terraform plan -destroy
Plan: 0 to add, 0 to change, 1 to destroy.
╷
│ Error: Instance cannot be destroyed
│
│ on main.tf line 10:
│ 10: resource "local_file" "protected" {
│
│ Resource local_file.protected has lifecycle.prevent_destroy set, but the
│ plan calls for this resource to be destroyed. To avoid this error and
│ continue with the plan, either disable lifecycle.prevent_destroy or reduce
│ the scope of the plan using the -target option.
╵

Note that Terraform still computes the plan (Plan: 0 to add, 0 to change, 1 to destroy prints first) — prevent_destroy is checked and stops execution only after the plan already decided a destroy is needed, not before.

create_before_destroy flips the default replace order. Normally, replacing a resource is destroy-then-create (-/+ in a plan); with this set, Terraform creates the replacement first and only destroys the old one once the new one exists — essential for anything where a moment of “neither exists” is unacceptable, like a google_compute_instance behind a load balancer.

resource "local_file" "app" {
filename = "${path.module}/app-${var.content}.txt"
content = var.content
lifecycle {
create_before_destroy = true
}
}

Real plan after changing var.content (which forces filename to change too, and filename forces replacement):

$ terraform plan
# local_file.app must be replaced
+/- resource "local_file" "app" {
~ content = "v1" -> "v2" # forces replacement
~ filename = "./app-v1.txt" -> "./app-v2.txt" # forces replacement
# (2 unchanged attributes hidden)
}
Plan: 1 to add, 0 to change, 1 to destroy.

The symbol is the tell: +/- (create then destroy) instead of the default -/+ (destroy then create) seen everywhere else in this course. create_before_destroy changes that ordering symbol and nothing else about the plan’s line count.

ignore_changes tells Terraform to stop treating changes to specific arguments as drift — useful when something outside Terraform (an autoscaler, a person editing labels in the Cloud Console) is expected to change a value, and re-asserting the original value on every apply would fight it.

resource "terraform_data" "app" {
input = {
labels = { managed_by = "terraform" }
}
lifecycle {
ignore_changes = [input]
}
}

Real plan after changing input in the configuration itself:

$ terraform plan
No changes. Your infrastructure matches the configuration.

Without ignore_changes = [input], that same edit to the configuration would plan a real update. With it, Terraform never even compares the current input against the new configuration value for that argument — the change is invisible to plan, not merely allowed to happen once and then re-tracked.

replace_triggered_by (covered in depth with a real proof in the Count and For Each lesson) forces a resource to be replaced whenever a referenced value changes, even if none of the resource’s own arguments changed — the inverse problem from ignore_changes: instead of hiding a change, it manufactures a replacement from a change that would otherwise be invisible to this resource.

terraform graph prints the actual dependency graph Terraform computed — not a description of it, the real DOT output GraphViz (or any DOT-reading tool) can render directly. Run without -type, it defaults to a resource-level summary graph, which is the clearest one to read by hand, and needs no GCP credentials at all — it never calls the Google Cloud API, only resolves references already in the configuration:

$ terraform graph
digraph G {
rankdir = "RL";
node [shape = rect, fontname = "sans-serif"];
"data.google_compute_image.debian" [label="data.google_compute_image.debian"];
"google_compute_instance.web" [label="google_compute_instance.web"];
"google_compute_network.vpc" [label="google_compute_network.vpc"];
"google_compute_subnetwork.app" [label="google_compute_subnetwork.app"];
"google_storage_bucket.logs" [label="google_storage_bucket.logs"];
"google_compute_instance.web" -> "data.google_compute_image.debian";
"google_compute_instance.web" -> "google_compute_subnetwork.app";
"google_compute_subnetwork.app" -> "google_compute_network.vpc";
}

That is the real graph for the google_compute_network.vpc → google_compute_subnetwork.app → google_compute_instance.web chain shown earlier in this lesson, plus an unrelated google_storage_bucket.logs — read against rankdir = "RL" (right-to-left), an edge A -> B means “A depends on B,” matching exactly the implicit-dependency reasoning from the previous section: google_compute_instance.web depends on both data.google_compute_image.debian and google_compute_subnetwork.app, and google_compute_subnetwork.app depends on google_compute_network.vpc. google_storage_bucket.logs has no edges to or from anything, which is exactly why Terraform is free to create it in parallel with the network chain.

flowchart RL
  instance["google_compute_instance.web"] --> image["data.google_compute_image.debian"]
  instance --> subnet["google_compute_subnetwork.app"]
  subnet --> network["google_compute_network.vpc"]
  bucket["google_storage_bucket.logs (no edges: parallel)"]
The same dependency graph terraform graph printed above, redrawn

Provider protocol: what actually happens during terraform init

Section titled “Provider protocol: what actually happens during terraform init”

Every provider — hashicorp/google included — is a separate, independently-versioned binary that Terraform Core downloads and runs as a subprocess, communicating over gRPC using a fixed protocol (protocol version 5 or 6, depending on the provider’s SDK). This is why terraform init has to download a specific binary per OS/architecture rather than Terraform simply “knowing” how to talk to GCP, AWS, or Azure out of the box — the provider plugin protocol, the plugin handshake, and why a provider crash produces a specific kind of log output are covered in depth, with a real TF_LOG=TRACE trace of a provider starting up, in Provider Plugin Protocol in the Terraform Internals module.

Everything in this lesson serves one job: helping Terraform build an accurate plan before it ever touches GCP. resource/data blocks and their references build the dependency graph; lifecycle overrides are exceptions layered on top of that graph’s normal behavior for one resource at a time (a hard stop, a reordering, an intentional blind spot, an extra edge) — none of them change what the graph looks like, only what Terraform does once it reaches that node. The provider itself is a separate process Terraform Core talks to over a fixed protocol, which is why a “provider” and “Terraform” can version independently — required_providers pins one, required_version pins the other.

ChoiceBenefitCost
prevent_destroyA hard stop on accidental destroys, proven above with a real error before any API callBlocks every destroy, including a deliberate one — must be removed from code (not just -targeted around) to actually tear the resource down
create_before_destroyNo “neither exists” window during replacement, proven above via the +/- ordering symbolBriefly needs two copies to coexist — a problem for anything with a uniqueness constraint (a fixed name, a reserved static IP) unless that argument also changes
ignore_changesStops Terraform fighting an out-of-band change, proven above with a real “No changes” despite a config editThe ignored argument’s true value in the console/API can drift arbitrarily far from what’s in .tf, with plan never mentioning it
replace_triggered_byMakes an otherwise-invisible dependency force a real replacement, deliberatelySame cost as any forced replacement: total destroy + recreate, not a partial update
terraform graph (default, no -type)Clean, resource-level summary — the one to read by hand, proven above, and needs no GCP credentialsOmits provider nodes, (expand) internals, and plan-specific actions that -type=plan shows — not the full picture Terraform actually walks
  • “prevent_destroy stops Terraform from even computing a destroy plan.” Proven above: the plan summary line (Plan: 0 to add, 0 to change, 1 to destroy) prints before the error — prevent_destroy intercepts execution after planning, not before.
  • “create_before_destroy avoids a plan-diff line count change.” It changes the symbol (+/- vs -/+), proven above, not the line count — the same 1 to add, 0 to change, 1 to destroy prints either way.
  • “ignore_changes only ignores the first drift, then re-syncs.” Proven above: it is not a one-time skip — every subsequent plan continues to treat that argument as invisible, indefinitely, until the lifecycle block itself is edited.
  • “Terraform applies resources in the order they’re written in the file.” Proven above with a real terraform graph: order comes entirely from the dependency graph, not file position — google_storage_bucket.logs, written last, has no edges at all and can apply first.
  • “A provider is part of the Terraform binary, compiled in.” It’s a separate downloaded binary run as a subprocess over gRPC — covered with a real trace in the Terraform Internals module; this is why terraform init needs network access for a provider Terraform itself doesn’t ship with.
  • A lifecycle { prevent_destroy = true } added to every resource “for safety,” including ones that genuinely need to be destroyed as part of normal operation (a scratch/ephemeral resource, anything behind count/for_each that legitimately shrinks) — proven above, this turns an intended destroy into a hard error that has to be worked around by editing code, not a soft warning.
  • create_before_destroy = true added to a resource with a uniqueness constraint on an argument that isn’t changing (a fixed name, a reserved static IP) — the replacement will fail to create because the old one, not yet destroyed, still holds that name.
  • ignore_changes = [labels] (or worse, ignore_changes = all) copied from an example without checking whether this resource’s labels are meant to be managed by Terraform — proven above, this silently and indefinitely hides drift, not just once.
  • A missing depends_on where a comment claims “X must exist before Y” but no argument reference actually encodes that — proven above, an implicit dependency requires a real attribute reference; a comment alone does not enter the graph at all.
  • A generated google_compute_instance with no network_interface block at all — this is not optional; terraform validate rejects it because network_interface is a required block.
  • Code claiming a specific provider version behavior without checking required_providers — the provider protocol section above is a reminder that “Terraform” and “the Google provider” are two independently-versioned pieces of software talking over a fixed wire protocol.

🐛 Spot the bug

resource "google_storage_bucket" "assets" {
name = "acme-assets-prod"
location = "US"
storage_class = "STANDARD"
lifecycle {
create_before_destroy = true
}
}
Show the answer

name is not shown changing here, but it doesn’t need to be — GCS bucket names must be globally unique across all of Google Cloud Storage, not just within a project. If any change to this resource ever forces a replacement (location is a real ForceNew argument on this resource, confirmed in the provider source), create_before_destroy tries to create the new acme-assets-prod before destroying the old one — and GCS rejects the create outright, because the old acme-assets-prod bucket still exists and holds that name globally. The plan-time symptom is a real apply-time failure (not caught by plan, since plan cannot ask GCS to reserve a globally-unique name), something like “409 Conflict: the requested bucket name is not available.” The fix is either to drop create_before_destroy for this resource (accept the destroy-then-create window) or to make name itself vary between replacements (e.g. include a random_id suffix) so the new and old names never collide while both exist.

In resource "google_compute_instance" "web" { ... }, what do the two labels represent
What is the key difference between a resource block and a data block
How does Terraform decide the order in which to create resources
Which correctly describes the terraform block required_providers argument
What happens when terraform plan -destroy is run against a resource with lifecycle.prevent_destroy = true?
In a terraform plan diff, what does the +/- symbol (versus the default -/+) indicate for a replaced resource?
A resource argument is listed in lifecycle.ignore_changes. The argument's value is then changed directly in the .tf file. What does the next terraform plan show?
Does terraform graph (default, no -type flag) need GCP credentials to run?