HCL, Providers, and Resources
The idea in one sentence
Section titled “The idea in one sentence”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 block syntax
Section titled “HCL block syntax”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 and provider blocks
Section titled “The terraform and provider blocks”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.
data blocks: reading without owning
Section titled “data blocks: reading without owning”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.
The dependency graph
Section titled “The dependency graph”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.appreferencinggoogle_compute_network.vpc.id, or an instance referencing a subnetwork’s ID), Terraform infers that the referenced resource must exist first. - Explicit dependencies —
depends_onon 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)"]
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 -destroyPlan: 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 planNo 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.
Real terraform graph output
Section titled “Real terraform graph output”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 graphdigraph 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)"]
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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
prevent_destroy | A hard stop on accidental destroys, proven above with a real error before any API call | Blocks every destroy, including a deliberate one — must be removed from code (not just -targeted around) to actually tear the resource down |
create_before_destroy | No “neither exists” window during replacement, proven above via the +/- ordering symbol | Briefly 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_changes | Stops Terraform fighting an out-of-band change, proven above with a real “No changes” despite a config edit | The 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_by | Makes an otherwise-invisible dependency force a real replacement, deliberately | Same 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 credentials | Omits provider nodes, (expand) internals, and plan-specific actions that -type=plan shows — not the full picture Terraform actually walks |
Common Misconceptions
Section titled “Common Misconceptions”- “
prevent_destroystops 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_destroyintercepts execution after planning, not before. - “
create_before_destroyavoids a plan-diff line count change.” It changes the symbol (+/-vs-/+), proven above, not the line count — the same1 to add, 0 to change, 1 to destroyprints either way. - “
ignore_changesonly ignores the first drift, then re-syncs.” Proven above: it is not a one-time skip — every subsequentplancontinues to treat that argument as invisible, indefinitely, until thelifecycleblock 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 initneeds network access for a provider Terraform itself doesn’t ship with.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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 behindcount/for_eachthat 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 = trueadded to a resource with a uniqueness constraint on an argument that isn’t changing (a fixedname, 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_onwhere 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_instancewith nonetwork_interfaceblock at all — this is not optional;terraform validaterejects it becausenetwork_interfaceis 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.