Learn Terraform
Understand the Machine, Not Just the Syntax
Terraform becomes easy to type long before it becomes safe to operate. This course is designed around the question that causes many real incidents: "Why does Terraform want to destroy that?"
By the end of this course you will be able to look at a plan and explain every action, every dependency, and every risk.
How to study this guide
Terraform rewards prediction. At each Pause and predict box:
- stop reading;
- say the expected plan out loud;
- include resource addresses, not just resource names;
- decide whether each action is create, update, replace, destroy, read, or no-op;
- continue and compare your model with the explanation.
The final workshop uses only HashiCorp's random and local providers. It creates names and local JSON files rather than paid cloud infrastructure, but it still exercises real provider installation, resource identity, state, drift, imports, tests, refactoring, and destruction.
Examples use the Terraform language's native HCL syntax. Commands assume Terraform CLI 1.7 or later unless a feature is explicitly marked newer.
The Map You Will Carry
Terraform always operates across three different worlds:
flowchart LR
C["CONFIGURATION\nwhat your .tf files describe"]
S["STATE\nTerraform's durable memory"]
R["REMOTE REALITY\nobjects behind provider APIs"]
C -->|"desired arguments\nand relationships"| P["PLAN"]
S -->|"prior identities\nand last observations"| P
R -->|"fresh reads through providers"| P
P -->|"proposed actions"| A["APPLY"]
A -->|"API calls"| R
A -->|"new identities and values"| S
These worlds are related, but they are not interchangeable.
| World | Contains | Does not guarantee |
|---|---|---|
| Configuration | Blocks, arguments, expressions, desired relationships | That objects already exist |
| State | Resource-address mappings, provider IDs, observed attributes, outputs | That remote reality is still unchanged |
| Remote reality | Actual cloud, SaaS, network, or local objects | That Terraform knows or owns them |
A useful approximation is:
plan =
provider-aware difference(
evaluated configuration,
refreshed objects identified by prior state
)
The provider schema and lifecycle rules decide whether a difference is an in-place update or a replacement. The dependency graph decides ordering. Unknown values delay parts of the answer until apply.
This model is more important than any individual command.
Opening Puzzle — The Rename That Can Delete a Server
Imagine this configuration already created a production server:
resource "example_server" "api" {
name = "production-api"
size = "small"
}
Its Terraform address is:
example_server.api
Someone improves the local label:
resource "example_server" "application" {
name = "production-api"
size = "small"
}
No remote argument changed. The real server's name is still production-api.
Will Terraform see:
- a harmless code-only rename;
- an in-place update;
- no change;
- one destroy and one create?
Terraform normally sees one address removed and another address added:
- example_server.api destroy
+ example_server.application create
Why? Terraform does not identify the object by visually comparing every argument and guessing that two blocks "look like the same server." Its state associates a remote provider ID with a resource address.
Before the edit:
example_server.api ──owns──> provider object srv-91f2
After the edit, the address example_server.api is absent from configuration, so its object is no longer desired there. example_server.application is a new address with no object attached.
The safe refactor records intent:
moved {
from = example_server.api
to = example_server.application
}
Now Terraform moves the state association before planning changes:
example_server.application ──owns──> provider object srv-91f2
Terraform manages durable associations between resource addresses in configuration and object identities returned by providers. Everything else in this guide grows from that sentence.
Feel One Complete Terraform Run
We begin with a resource that changes no external infrastructure. The built-in terraform_data resource follows Terraform's normal resource lifecycle and stores an input value in state.
Create an empty directory containing main.tf:
terraform {
required_version = ">= 1.7.0, < 2.0.0"
}
variable "learner" {
type = string
description = "Name included in the lesson record."
default = "Ada"
}
resource "terraform_data" "lesson" {
input = {
message = "Terraform remembers identity"
learner = var.learner
}
}
output "lesson_record" {
value = terraform_data.lesson.output
}
No provider block is required. terraform_data comes from a provider built into Terraform.
Step 1 — initialize the working directory
terraform init
init prepares the directory. Depending on the configuration, it: initializes the selected state backend; downloads child modules; installs provider plugins; reads or creates the dependency lock file; creates local working data under .terraform/. It does not normally create the declared infrastructure.
Step 2 — normalize and validate
terraform fmt
terraform validate
fmt rewrites Terraform files into canonical style. validate checks syntax, types, references, and internal consistency. It does not prove that credentials work, that a remote API accepts the request, or that quotas are available.
Step 3 — plan
terraform plan
The first plan should show:
+ terraform_data.lesson
Plan: 1 to add, 0 to change, 0 to destroy.
The resource ID is shown as id = (known after apply). Terraform knows a resource will exist, but the provider/runtime has not created it and returned its final ID. This is an unknown value, not null, not an empty string, and not a random placeholder.
Step 4 — apply exactly what you reviewed
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan
The saved plan is a machine-readable artifact. Applying it executes the actions in that plan rather than silently calculating a fresh one. Do not commit tfplan — saved plans can contain full configuration values, backend details, and sensitive data.
Step 5 — inspect the result from several angles
terraform output
terraform state list
terraform state show terraform_data.lesson
terraform show
| Command | Question |
|---|---|
terraform output | What interface values does the root module expose? |
terraform state list | Which addresses are tracked? |
terraform state show ADDRESS | What is recorded for one tracked object? |
terraform show | What is the current state snapshot in human-readable form? |
Step 6 — prove idempotence
terraform plan
If configuration, state, and observed reality agree:
No changes. Your infrastructure matches the configuration.
Idempotence does not mean "Terraform commands never change anything." It means repeatedly applying the same desired model converges to a stable result.
Step 7 — change and predict
Change:
message = "State connects addresses to objects"
Will the resource update or be replaced?
For terraform_data.input, Terraform can plan an in-place update:
~ terraform_data.lesson
Now change triggers_replace:
triggers_replace = "revision-2"
Changing triggers_replace causes replacement because that is the documented semantics of the argument. Whether a normal provider argument updates or replaces an object comes from provider schema and planning logic, not from the visual size of the change.
Step 8 — destroy
terraform plan -destroy
terraform destroy
destroy is essentially a planning mode in which the desired configuration contains none of the managed resource instances. It destroys what the current state tracks—not every object in the account.
What the first run secretly taught you
You have already encountered the entire machine:
sequenceDiagram
autonumber
participant U as You
participant T as Terraform Core
participant B as Backend
participant P as Provider
U->>T: terraform plan
T->>B: lock and read state
T->>T: load configuration and graph
T->>P: read existing objects
P-->>T: refreshed values
T->>P: plan proposed changes
P-->>T: update or replace decisions
T-->>U: execution plan
U->>T: terraform apply saved-plan
T->>B: acquire lock and verify state
T->>P: create/update/delete calls
P-->>T: IDs and final attributes
T->>B: persist new state
T->>B: unlock
Terraform is not a continuously running controller. When the command ends, Terraform normally exits. If drift happens an hour later, no local Terraform daemon repairs it. The next plan, apply, or external drift-detection run must observe it.
| Terraform | Kubernetes |
|---|---|
| Runs on demand and exits | Controllers run continuously |
| Manages resources through many provider APIs | Reconciles Kubernetes API objects |
| Uses durable state to map addresses to provider objects | Desired/observed cluster state lives in Kubernetes |
| A plan is a reviewable proposed transaction | Controllers continually converge without a human plan |
Both are declarative. Declarative does not imply continuous.
How a Plan Is Actually Formed
A plan is not merely a text diff between old.tf and new.tf. It is a proposed transition built from configuration, state, live reads, provider semantics, and graph constraints.
The three comparisons
flowchart TD
PS["Prior state\naddress → provider ID + cached attributes"]
LIVE["Provider reads live object\nby provider ID"]
REF["Refreshed state in memory"]
CFG["Evaluated configuration"]
SCHEMA["Provider schema and planning rules"]
PLAN["Execution plan"]
PS -->|"which object should I read?"| LIVE
LIVE --> REF
REF --> PLAN
CFG --> PLAN
SCHEMA --> PLAN
Case A — configuration changed
configuration: size = "large"
state/live: size = "small"
Plan: update or replace, depending on provider behavior.
Case B — remote drifted
configuration: size = "small"
prior state: size = "small"
live object: size = "large"
After refresh, Terraform sees "large" and normally proposes returning the object to "small".
Case C — remote object disappeared
state: address points to object srv-91f2
provider read: object srv-91f2 not found
configuration: address still declared
Terraform normally proposes creating a replacement object.
Case D — unmanaged object exists
remote reality: object srv-77aa exists
configuration: describes a similar server
state: no association
Terraform normally plans a new object. It does not scan the entire account and adopt things that look similar. Adoption requires an import.
Provider schemas decide the meaning of arguments
Terraform Core understands graphs, values, addresses, state, and lifecycle. Providers understand remote resource types.
flowchart LR
HCL["resource configuration"] --> CORE["Terraform Core"]
CORE --> RPC["provider protocol"]
RPC --> PROV["provider plugin"]
PROV --> API["cloud / SaaS / local API"]
API --> PROV --> CORE
Terraform itself does not know what an AWS subnet, a GitHub repository, or a Cloudflare DNS record means. The relevant provider does. This explains why: provider upgrades can change plans without HCL changes; one attribute updates in place while another forces replacement; API normalization can turn "Example" into "example"; eventual consistency can cause transient read errors; provider bugs can produce incorrect plans.
Unknown is a first-class value
resource "example_network" "main" {
cidr = "10.0.0.0/16"
}
resource "example_server" "api" {
network_id = example_network.main.id
}
Before the network exists, its ID is unknown. Therefore network_id is also unknown:
example_network.main.id = (known after apply)
example_server.api.network_id = (known after apply)
flowchart LR
N["example_network.main"] -->|"id becomes known"| S["example_server.api"]
During apply, Terraform creates the network, receives its ID, then supplies that ID when creating the server.
| Value | Meaning |
|---|---|
"abc" | Known string |
"" | Known empty string |
null | Intentionally absent; often lets provider/default logic decide |
| unknown | A value will exist, but planning cannot yet know it |
| sensitive | Value exists but normal UI should redact it |
| ephemeral | Value is intentionally omitted from state/plan where supported |
Unknowns restrict graph shape
Terraform must know how many resource instances and their addresses during planning. This is invalid when the keys are unknown:
resource "example_server" "node" {
for_each = toset(example_network.main.generated_subnet_ids)
subnet_id = each.value
}
Design the keys from configuration-known values instead:
variable "subnets" {
type = map(object({
cidr = string
}))
}
resource "example_subnet" "this" {
for_each = var.subnets
cidr = each.value.cidr
}
resource "example_server" "node" {
for_each = var.subnets
subnet_id = example_subnet.this[each.key].id
}
The plan is useful, not prophetic
A plan is computed from observations at a point in time. Between plan and apply: another operator may change state; a cloud object may drift; credentials may expire; quotas may be consumed; the API may reject a name; an external dependency may disappear.
"Given the configuration, state, live observations, provider versions, credentials, and API responses available during planning, these are the actions Terraform currently proposes." That is powerful—just not omniscient.
Read the symbols before the prose
| Symbol | Meaning |
|---|---|
+ | Create |
~ | Update in place |
- | Destroy |
-/+ | Destroy, then create a replacement |
+/- | Create replacement, then destroy old object |
<= | Read a data source during apply |
Plan modes are different questions
terraform plan # "What changes would make managed objects match configuration?"
terraform plan -destroy # "What would be destroyed if the desired managed set were empty?"
terraform plan -refresh-only # "What state/output updates would accept the live observations?"
terraform plan -replace='NAME' # "What would happen if this instance were replaced?"
terraform plan -target='module' # "What partial plan is necessary for this selected address?"
Configuration and state both say replicas = 3. Someone changes the live object to replicas = 5. What will a normal plan do? What will plan -refresh-only do?
Normal plan refreshes to 5 and generally proposes changing reality back to 3. Refresh-only proposes updating state to record 5, without changing reality. Applying refresh-only accepts the out-of-band change into state, but the unchanged configuration still says 3. A later normal plan may therefore propose 3 again. To adopt the drift as desired, configuration must also change.
The First Checkpoint
Before continuing, explain these without looking back:
- Why can a code-only resource label rename cause destruction?
- What information comes from configuration, state, and a provider read?
- Why does an unmanaged but similar object not prevent Terraform from creating one?
- What is the difference between
nulland unknown? - Why must
for_eachkeys be known during planning? - What does a saved plan guarantee, and what can still change?
- Why is
-targetnot a normal way to divide a large system?
If any answer feels vague, reread the opening puzzle and the three-world map. They are the foundation for everything that follows.
HCL Builds Values and Relationships
Terraform configuration is not a sequence of API instructions. It is a collection of blocks whose expressions produce values and dependencies.
resource "example_network" "main" {
cidr = var.network_cidr
tags = merge(local.common_tags, {
Name = "${var.project}-${var.environment}"
})
}
Break it apart:
resource "example_network" "main" {
│ │ │
│ │ └─ local Terraform label
│ └─ provider-defined resource type
└─ block type
cidr = var.network_cidr
│ └─ expression producing a value and dependency
└─ provider-defined argument
}
Files do not execute top to bottom
Terraform loads all top-level .tf and .tf.json files in one directory as a single module. File names and ordering are for humans. A block in z-last.tf can be an upstream dependency of a block in a-first.tf. Nested directories are separate modules and are not loaded automatically. Do not use filenames to model creation order. Use references.
The blocks and what they mean
| Block | Role |
|---|---|
terraform | CLI version, provider requirements, backend/cloud settings |
provider | Configures credentials, region, endpoint, or alias |
variable | Input contract of a module |
locals | Names reusable expressions inside a module |
resource | Declares lifecycle management of remote/logical objects |
data | Reads objects or computed information without owning lifecycle |
module | Calls another Terraform module |
output | Publishes a module interface value |
moved | Declares a resource-address refactor |
import | Associates an existing object with an address |
removed | Stops managing an address, optionally without destroying its object |
check | Evaluates a non-blocking operational assertion |
Values and types
| Kind | Example |
|---|---|
| string | "eu-west-2" |
| number | 3 |
| bool | true |
| list | ["a", "b"] |
| tuple | ["a", 2, true] |
| set | toset(["a", "b"]) |
| map | { api = 2, worker = 4 } |
| object | { name = "api", size = 2 } |
| null | null |
Precise types turn assumptions into early errors and give module consumers a usable contract. Lists and sets are not interchangeable — a list has order and duplicates; a set has unique unordered membership. Converting to a set is ideal for for_each when each string is a stable identity.
Input variables are an API
Good input:
variable "environment" {
type = string
description = "Deployment environment used in names and tags."
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
Every variable expands the module's public API and testing surface. Expose decisions that genuinely vary between callers; keep internal implementation details in locals. Do not put environment-specific values in reusable child-module defaults when doing so would surprise consumers.
Locals transform; they do not persist
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
Locals give names to expressions, reduce duplication, create readable intermediate values, are recalculated during evaluation, are not input variables, and do not have their own state identity.
Outputs are module interfaces, not print statements
output "network" {
description = "Network identity and subnet IDs for downstream modules."
value = {
id = example_network.main.id
subnet_ids = values(example_subnet.this)[*].id
}
}
At the root module, outputs are stored in state and shown after apply. In a child module, outputs are how the caller receives selected values. Do not output an entire resource object merely because it is convenient. Publish a small, intentional interface.
Expressions create implicit dependencies
resource "example_subnet" "app" {
network_id = example_network.main.id
cidr = "10.0.1.0/24"
}
The reference example_network.main.id does two jobs: passes a value and creates a graph edge. Terraform knows it must create/read the network before it can create the subnet. Prefer implicit dependencies because they carry the actual data relationship.
Use depends_on only for a real ordering relationship that carries no direct value. Overusing it makes the graph conservative, increases unknown values, and can serialize work unnecessarily.
Data sources read; resources own lifecycle
data "example_image" "base" {
family = "approved-linux"
}
resource "example_server" "api" {
image_id = data.example_image.base.id
}
The data source queries provider information but does not create/update/delete the selected image. It can still influence downstream replacements when its result changes. If arguments depend on unknown values, reads may be deferred until apply as <= read during apply.
for expressions reshape data
locals {
public_services = {
for name, service in var.services :
name => service
if service.public
}
}
The syntax is a data transformation, not a loop that makes API calls. API operations come from resource and data instances created by the evaluated result.
Conditionals and dynamic blocks
locals {
retention_days = var.environment == "prod" ? 365 : 14
}
resource "example_monitor" "critical" {
count = var.environment == "prod" ? 1 : 0
# address becomes example_monitor.critical[0]
}
Adding count changes the address shape. That matters during refactors. For many independently identified instances, prefer for_each.
dynamic blocks generate nested blocks inside one resource instance. Use it when the provider requires nested blocks and the repetition is real. Avoid clever nested dynamic structures that make the plan impossible to review.
Use the console to understand values
terraform console
> toset(["api", "worker", "api"])
> cidrsubnet("10.0.0.0/16", 8, 3)
> { for x in ["a", "bb"] : x => length(x) }
> jsonencode({ name = "api", replicas = 3 })
The Graph and the Identity Problem
Terraform translates evaluated blocks into a graph of resource instances:
flowchart LR
VPC["example_network.main"]
SA["example_subnet.app['a']"]
SB["example_subnet.app['b']"]
APIA["example_server.api['a']"]
APIB["example_server.api['b']"]
LB["example_load_balancer.public"]
VPC --> SA --> APIA --> LB
VPC --> SB --> APIB --> LB
Independent graph branches can run concurrently, subject to provider and CLI parallelism limits. Textual block order does not control it. The graph is why references are more than string interpolation.
Blocks and instances are different
One block:
resource "example_server" "api" {
for_each = {
blue = "small"
green = "large"
}
size = each.value
}
Two instances:
example_server.api["blue"]
example_server.api["green"]
State tracks instances, not just source blocks. This distinction matters for imports, replacements, state inspection, moved blocks, targeted recovery, and plan review.
count uses position as identity
variable "names" {
default = ["api", "worker", "scheduler"]
}
resource "example_server" "service" {
count = length(var.names)
name = var.names[count.index]
}
Addresses: example_server.service[0] (api), example_server.service[1] (worker), example_server.service[2] (scheduler).
Remove "worker" from the middle and Terraform still reasons by index: [0] remains api, [1] changes worker → scheduler, [2] is removed. Depending on provider semantics, this can update/replace the worker into a scheduler and destroy the former scheduler. Humans thought in names; state thought in indexes.
for_each uses keys as identity
variable "services" {
default = {
api = "small"
worker = "medium"
scheduler = "small"
}
}
resource "example_server" "service" {
for_each = var.services
name = each.key
size = each.value
}
Addresses: example_server.service["api"], example_server.service["worker"], example_server.service["scheduler"]. Remove "worker" — only example_server.service["worker"] is destroyed. The keys express business identity.
Given for_each = toset(["api", "worker"]), change to toset(["worker", "api"]). Plan? Then change "api" to "backend". Plan?
Reordering: no instance identity change — sets are unordered. Renaming: destroy ["api"], create ["backend"], unless you add a moved block.
Chaining for_each preserves the keyspace
resource "example_network" "environment" {
for_each = var.environments
name = each.key
}
resource "example_subnet" "application" {
for_each = example_network.environment
network_id = each.value.id
name = "${each.key}-application"
}
Both resource families share keys. Terraform understands that their instance identities change together.
Why graph knowledge improves design
When a plan contains too many unknowns, ask: Is a broad module-level depends_on delaying more work than necessary? Are instance keys derived from apply-time IDs? Is a data source querying something created in the same plan? When a graph contains cycles: create a base object first and configure the relationship later; pass existing IDs into a module; split bootstrapping from steady-state management.
State — Terraform's Memory, Ownership Ledger, and Risk Concentration
State is often introduced as "a cache." That is incomplete. State is also: an identity map from resource address to provider object; a record of last-known attributes; provider configuration association metadata; dependency information needed for operations such as destroy; module output storage; lineage and serial metadata for safe updates.
Without state, Terraform cannot reliably know that example_server.api corresponds to provider object srv-91f2 in account production. Names are not sufficient — APIs allow duplicates, renames, generated IDs, and provider-specific composite identities.
State is not a database backup
State may record a database resource's ID, endpoint, engine version, configured size, and selected attributes. It does not contain the actual rows of the database merely because Terraform created the database service. Back up application data using the storage system's mechanisms. Back up Terraform state because losing it loses Terraform's ownership mappings.
Local state is a learning default, not a team architecture
By default, Terraform writes terraform.tfstate and terraform.tfstate.backup locally. Local state is useful for learning, disposable experiments, and isolated personal work with no valuable resources. It is weak for teams: one machine holds the authoritative copy; collaborators can plan from stale copies; simultaneous writers can conflict; laptop loss can lose ownership mappings.
Backends store state and may provide locking
terraform {
backend "example" {
# backend-specific, constant configuration
}
}
Backend blocks cannot use input variables, locals, data sources, or resource attributes — Terraform must initialize the backend before it can evaluate the normal configuration. After a backend change, terraform init may ask whether to migrate existing state. Do not hardcode backend credentials.
Locking prevents two writers, not every race
sequenceDiagram
participant A as Run A
participant B as Backend lock
participant C as Run B
A->>B: acquire lock
B-->>A: granted
C->>B: acquire lock
B-->>C: wait/fail
A->>B: write new state
A->>B: release lock
B-->>C: lock available
Use a bounded wait in automation: terraform plan -lock-timeout=5m. Avoid -lock=false. State locking does not prevent: a human changing the cloud console; a different state managing the same object; an external system altering shared settings; API changes between plan and apply.
State contains secrets more often than people expect
If a value is supplied to or returned by a provider resource, it may be stored in state: generated passwords, private keys, connection strings, user-data scripts, tokens, database bootstrap values. Marking a variable or output sensitive = true redacts it from normal CLI/UI display — it does not remove it from state or saved plans.
| Mechanism | Hides normal display | Omitted from state/plan |
|---|---|---|
sensitive = true | Yes | No |
| ephemeral value | Use with sensitive if display also matters | Yes |
| provider write-only argument | Provider-specific behavior | Yes |
Inspect through Terraform, do not hand-edit JSON
terraform state list
terraform state show 'example_server.api["blue"]'
terraform show
terraform output
terraform state pull # contains raw state — treat as sensitive!
State boundaries are architecture boundaries
| Signal | Split when... |
|---|---|
| Ownership | Different teams independently own lifecycle |
| Permissions | Resources require materially different credentials |
| Blast radius | A failed plan should not affect the other component |
| Change cadence | Components release on different schedules |
| Lifecycle | One component outlives or is replaced independently |
| Scale | Refresh/plan time or state size is operationally painful |
CLI workspaces: same configuration, different state instances
terraform workspace list
terraform workspace new dev
terraform workspace select prod
CLI workspaces give one working directory multiple state instances. They are not strong system decomposition — the same backend configuration is shared, switching is implicit, and permissions are harder to separate. For strongly separated production/dev environments, prefer explicit root configurations/backends calling shared modules.
Terraform created ten objects. The state is permanently lost, but the objects remain. You still have the .tf files. Will terraform plan reconstruct the mappings automatically?
No. Configuration describes desired resource addresses and arguments, but it does not contain the provider IDs returned during the original creates. Terraform generally plans ten new objects. Recovery requires locating each real provider identity, confirming the correct address, importing each object, planning to reconcile configuration with real attributes, and resolving discrepancies.
Changing Ownership Without Changing Infrastructure
Terraform must distinguish four intentions that can look similar in a Git diff:
| Intent | Mechanism |
|---|---|
| Change object | Edit resource arguments and apply |
| Rename/move address | moved block |
| Adopt existing object | import block/command plus matching resource config |
| Stop managing, keep object | removed block with destroy = false |
| Stop managing and delete object | Remove resource block and apply, or explicit destroy workflow |
moved blocks preserve identity through refactoring
sequenceDiagram
participant C as Configuration
participant S as State
participant T as Terraform plan
C->>T: moved from old to new
T->>S: is old address tracked?
alt tracked
T->>S: reinterpret association at new address
T->>T: plan from existing object at new address
else not tracked
T->>T: continue — new address has no moved object
end
moved {
from = example_server.api
to = example_server.application
}
Safe plan: # example_server.api has moved to example_server.application. Suspicious plan: destroy followed by create. Always inspect the plan. Keep historical moved blocks in reusable modules — removing them breaks the upgrade path for consumers who skip intermediate versions.
Import is adoption, not discovery
resource "example_bucket" "audit" {
name = "company-audit-prod"
}
import {
to = example_bucket.audit
id = "company-audit-prod"
}
Import does not mean Terraform found everything automatically, the existing object matches your configuration, or related child objects are automatically imported. After import, always review the plan — it may propose updates or replacement because configuration does not match live settings or provider defaults.
- Identify the exact provider account, region, and object ID.
- Read that resource type's provider import documentation.
- Write or generate candidate resource configuration.
- Add an import block targeting the intended address.
- Plan.
- Reconcile every diff deliberately.
- Apply.
- Verify a subsequent no-op plan.
removed blocks make abandonment reviewable
removed {
from = example_server.legacy
lifecycle {
destroy = false
}
}
This is safer than an unreviewed imperative state edit because the intent lives in configuration, appears in plan, and can be code-reviewed. Before abandoning an object, decide who owns it next, how it will be changed and deleted, and whether secrets or outputs still reference it.
State commands are useful fallbacks, not a default refactor language
terraform state mv OLD_ADDRESS NEW_ADDRESS
terraform state rm ADDRESS
terraform state replace-provider OLD_SOURCE NEW_SOURCE
Prefer configuration blocks when available because every workspace/consumer can apply the same transition and the history remains reviewable.
Lifecycle Rules — Sharp Tools with Precise Purposes
create_before_destroy
Normal replacement is destroy old → create new. With create_before_destroy = true, Terraform tries: create new → redirect dependencies → destroy old. Useful when duplicate objects may coexist and names can be unique during overlap. It can fail when the API enforces a unique name, capacity/quota cannot hold both, or the old object owns an exclusive attachment. This rule is not a universal zero-downtime switch.
prevent_destroy
Terraform rejects a plan that would destroy the resource while this rule remains in configuration. Good candidates: production databases, audit buckets, signing keys, foundational networks. Limits: removing the resource block also removes the rule from configuration; it does not protect against cloud-console deletion; it can intentionally block terraform destroy.
A database has prevent_destroy = true. Someone deletes the entire resource block from configuration. Does the protection necessarily remain?
No. The rule disappeared with the block. Terraform can now plan destruction of the address still tracked in state. This is why organizational policy and provider-native deletion protection matter.
ignore_changes
This declares shared ownership: Terraform sets or observes an attribute but should ignore later differences for update planning. Legitimate uses: an autoscaler manages desired replica count; a policy engine writes one tag; a platform injects a value after creation. Risks: hides drift; leaves configuration claiming a value Terraform will not enforce; masks incidents or provider bugs.
replace_triggered_by
Force replacement based on another managed resource or attribute. Use when the downstream object cannot be updated safely after an upstream change, even though the provider does not naturally infer replacement.
-replace is an operational request
terraform plan -replace='example_server.api' -out=tfplan
terraform apply tfplan
Prefer -replace over terraform taint because the replacement request is visible in the plan operation rather than silently persisted in state ahead of review.
Providers — The Executable Half of Terraform
flowchart TD
REQ["required_providers\nsource + compatible versions"] --> LOCK[".terraform.lock.hcl\nselected provider version + checksums"]
LOCK --> INSTALL["terraform init\ninstalls plugin"]
CONFIG["provider block / environment\nendpoint + credentials + alias"] --> ASSOC["resource/provider association"]
INSTALL --> ASSOC
ASSOC --> API["API operations"]
Declare requirements
terraform {
required_version = ">= 1.7.0, < 2.0.0"
required_providers {
example = {
source = "acme/example"
version = "~> 3.2"
}
}
}
The source address identifies the provider globally. Every module declares its own provider requirements. Terraform selects one provider version compatible with the combined requirements for the whole configuration.
Commit the dependency lock file
terraform init writes .terraform.lock.hcl. Commit it for root configurations — it records the selected provider versions and checksums so local, CI, and remote runs install the same artifacts. Do not commit .terraform/.
Configure providers at the root
provider "example" {
region = var.region
}
Reusable child modules should declare requirements but normally receive provider configurations from their caller. Credentials should come from the provider's standard external mechanisms: workload identity, short-lived federation, environment variables, credential files.
Aliases model multiple configurations
provider "aws" {
region = "eu-west-2"
}
provider "aws" {
alias = "recovery"
region = "eu-central-1"
}
resource "aws_s3_bucket" "replica" {
provider = aws.recovery
bucket = "example-replica"
}
Provider upgrade discipline
Even with unchanged HCL, a new provider may normalize values differently, add computed fields, change defaults, fix a diff bug, mark an attribute replace-only, alter import identity parsing, or remove deprecated arguments. Upgrade process: read release notes, update the version constraint, run terraform init -upgrade, inspect lock file, format/validate/test, plan every representative state, separate provider upgrade from unrelated infrastructure changes.
Modules — Boundaries of Meaning
Every Terraform configuration directory is a module. The directory where Terraform runs is the root module. A module called through a module block is a child module.
module "network" {
source = "./modules/network"
name = "payments-prod"
cidr_block = "10.42.0.0/16"
}
The child module's resources remain in the root run's state:
module.network.example_network.this
module.network.example_subnet.this["private-a"]
A module is not automatically a separate state, a separate deployment, a security boundary, or a microservice. It is a reusable configuration abstraction within a root configuration.
A good module raises the level of conversation
Stronger abstraction hides decisions callers should not make and exposes decisions they must:
module "audit_archive" {
source = "app.example.com/platform/audit-archive/example"
version = "4.1.0"
name = "payments-prod"
retention_years = 7
writer_identities = [module.application.runtime_identity]
}
Compose modules flatly
flowchart LR
N["network module"] -->|"private subnet IDs"| D["database module"]
N -->|"application subnet IDs"| A["application module"]
D -->|"database address"| A
The root module connects explicit interfaces. Avoid deeply nested modules that secretly create all their dependencies. Flat composition improves reuse, dependency visibility, testing, swapping implementations, state refactoring, and policy review.
Module sources and versions
module "network" {
source = "app.example.com/platform/network/aws"
version = "~> 5.3"
}
module "network" {
source = "git::https://example.com/platform/network.git?ref=v5.3.2"
}
module "network" {
source = "./modules/network"
}
Environments: share modules, separate roots
modules/
└── application/
live/
├── dev/
│ ├── main.tf
│ └── backend.tf
├── stage/
│ ├── main.tf
│ └── backend.tf
└── prod/
├── main.tf
└── backend.tf
Cross-state dependencies
If application state needs a network ID from network state, options include: look up the network through the cloud provider using stable tags/IDs; publish the value in a configuration registry; consume explicitly exposed remote-state outputs; pass it through the deployment system. Avoid circular state dependencies — one boundary must own the shared relationship.
Before: example_bucket.logs. After wrapping the unchanged resource in module "storage", the address becomes module.storage.example_bucket.logs. Will Terraform automatically infer the move because the arguments are identical?
No. You must record a moved block:
moved {
from = example_bucket.logs
to = module.storage.example_bucket.logs
}
A module refactor is also an identity refactor.
The Safe Change Path — From Editor to Applied State
flowchart LR
EDIT["edit"] --> FMT["fmt"]
FMT --> VAL["validate"]
VAL --> TEST["tests"]
TEST --> PLAN["speculative plan"]
PLAN --> REVIEW["human + policy review"]
REVIEW --> MERGE["merge"]
MERGE --> FINAL["fresh saved plan"]
FINAL --> APPROVE["approval"]
APPROVE --> APPLY["apply exact plan"]
APPLY --> OBSERVE["post-apply checks"]
Layer 1 — formatting
terraform fmt -check -recursive
Layer 2 — static validation
terraform init -backend=false
terraform validate
Layer 3 — built-in configuration contracts
Variable validation rejects bad module input. Preconditions verify an assumption before acting. Postconditions verify a guarantee from a resource/data result — a failed postcondition can stop downstream work, but it cannot undo remote operations that already completed. Check blocks evaluate operational assertions; failed check assertions report warnings and do not block the Terraform operation.
Layer 4 — Terraform tests
// tests/manifests.tftest.hcl
run "development_plan" {
command = plan
variables {
environment = "dev"
}
assert {
condition = output.retention_days == 14
error_message = "Development retention must be 14 days."
}
}
terraform test
Terraform tests can create real infrastructure and therefore cost money. Use isolated credentials/accounts, unique naming, quotas, timeouts, cleanup, and provider mocking where appropriate.
Layer 5 — plan review
For pull requests, produce a speculative plan. After merge, produce a fresh saved plan:
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan
# In automation:
terraform plan -detailed-exitcode
# 0 = success, empty diff | 1 = error | 2 = success, non-empty diff
A destructive-change review
For every replacement or destruction, answer: What exact address is affected? Which remote object/account/region? Why did Terraform choose replacement? Is stateful data attached? Does deletion protection exist? Can old and new coexist? What depends on the object? What is the outage window? Is rollback possible? "Only one resource" is not a risk assessment.
Policy and organizational guardrails
Policy can reject plans that violate rules such as public storage, unrestricted ingress, missing encryption, disallowed regions, destruction of protected resource classes. Keep policies versioned, tested, understandable, and paired with actionable messages.
Credentials for automation
Prefer: CI identity → short-lived federation → scoped provider credentials. Avoid: long-lived admin access key → repository secret → every Terraform job.
Secrets in configuration
Mark variables sensitive = true. Use ephemeral = true and write-only arguments where supported. Best architecture often lets Terraform grant an application identity permission to read a secret at runtime rather than making Terraform carry the secret value itself.
Provisioners are a last resort
Provisioners weaken Terraform's model because Terraform usually cannot plan the script's effects, detect drift, or roll the action back. Prefer purpose-built provider resources, machine images, cloud-init/user data, configuration management, or application deployment systems.
Failure Clinic — Diagnose from the Layer That Failed
flowchart TD
F["Terraform failed or surprised you"] --> L1{"Initialization?"}
L1 -->|Yes| I["backend/module/provider install\nversions, network, auth, lock file"]
L1 -->|No| L2{"Configuration evaluation?"}
L2 -->|Yes| C["syntax, types, references,\nunknown keys, cycles"]
L2 -->|No| L3{"State/refresh?"}
L3 -->|Yes| S["workspace, backend, lock,\nprovider account/region, drift"]
L3 -->|No| L4{"Provider plan?"}
L4 -->|Yes| P["schema, defaults, replacement,\nprovider version/bug"]
L4 -->|No| L5{"Apply/API?"}
L5 -->|Yes| A["credentials, quota, API error,\neventual consistency, partial success"]
L5 -->|No| O["postconditions, checks,\napplication/system behavior"]
Failure: state lock cannot be acquired
Read lock owner, operation, time, and lock ID. Check the remote run queue/CI system. Contact the owner. Wait using -lock-timeout. Prove the original operation ended. Only then consider force-unlock. Never use -lock=false.
Failure: cycle detected
Example: security group rule needs load balancer ID; load balancer needs security group ID. Fix the model: create the security group without the rule, create the load balancer, create a separate rule resource referencing both. Do not add more depends_on — a dependency edge cannot solve a circular dependency.
Failure: invalid for_each argument
Keep identity keys configuration-known. Unknown values may appear inside each instance.
Failure: inconsistent final plan/result
Often indicates provider bug, unstable API normalization, or remote eventual consistency. Record Terraform version, provider version and checksums, smallest configuration, redacted plan/log output, state backup, and API evidence.
Failure: wrong account or region
Symptoms: all objects appear missing, plan proposes mass creation, refresh-only proposes removing many objects from state. Stop. Verify selected backend/workspace, provider alias, account identity, region/project, credential source, and environment variables.
Failure: apply stopped halfway
sequenceDiagram
participant T as Terraform
participant A as API object A
participant B as API object B
participant S as State
T->>A: create
A-->>T: success, id A-1
T->>S: record successful result
T->>B: create
B-->>T: quota error
T-->>T: apply fails
Recovery: preserve the full error output; do not immediately rerun with random changes; confirm the state write succeeded; inspect terraform state list/show; inspect remote reality through provider tooling; fix the root cause; run a fresh normal plan; review what Terraform now proposes; apply the remaining convergence. Never assume an error means "nothing changed."
Failure: manual change keeps returning
This is ownership conflict. Choose: Terraform owns it (stop the other writer); the other system owns it (remove/ignore the argument narrowly); split the resource/attachment; or redesign the interface. Repeated drift is often a governance problem, not a Terraform bug.
Failure: prevent_destroy blocks legitimate migration
Sequence: understand why replacement/destruction is planned; take/verify backups; enable provider-native deletion protection; design migration and rollback; narrow the code change that temporarily changes the lifecycle guard; review the exact saved plan; apply with approval; restore protection on the replacement; verify.
A diagnostic command ladder
terraform version
terraform workspace show
terraform providers
terraform state list
terraform state show 'ADDRESS'
terraform plan
terraform show tfplan
terraform graph
The Second Checkpoint — Explain the Dangerous Parts
- Why is deleting a resource block an infrastructure instruction?
- How does a moved block differ from
terraform state mv? - What must be reconciled after importing an object?
- Why is
ignore_changesa declaration of shared ownership? - Why can
create_before_destroystill produce downtime or fail? - What is stored in
.terraform.lock.hcl, and why commit it? - Why should reusable child modules not configure provider credentials?
- Why is a child module not a separate state?
- Why is an apply failure not evidence that nothing changed?
- What is the difference between a speculative plan and a final saved plan?
Field Workshop — Learn Terraform Without a Cloud Bill
This workshop creates names and local JSON files using the random and local providers — exercising real provider installation, resource identity, state, drift, imports, tests, refactoring, and destruction. Estimated time: 45–75 minutes.
flowchart LR
INPUT["set of environments\ndev · stage"] --> PET["random_pet instances\nstable generated names"]
PET --> FILE["local_file instances\none JSON manifest per key"]
FILE --> OUT["outputs\npaths + codenames"]
PET --> STATE[("Terraform state")]
FILE --> STATE
What you will prove: init resolves and locks provider versions; unknown values can flow through a graph whose keys are already known; for_each keys create stable addresses; a no-op plan represents convergence; drift is detected through provider reads; state records generated identities; a rename without moved means replacement; a rename with moved preserves objects; import establishes ownership; a removed block relinquishes ownership; terraform test can validate the module interface.
Prerequisites
Install Terraform CLI from HashiCorp's official installation instructions (1.7 or later). Optional tools: jq, git. The core lab needs network access during terraform init to download two providers but uses no cloud credentials.
Build the project
terraform-understanding-lab/
├── .gitignore
├── main.tf
├── outputs.tf
├── variables.tf
├── versions.tf
└── tests/
└── manifests.tftest.hcl
.gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log
generated/
override.tf
override.tf.json
*_override.tf
*_override.tf.json
Notice what is absent: .terraform.lock.hcl — commit the lock file for a root configuration.
versions.tf
terraform {
required_version = ">= 1.7.0, < 2.0.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
variables.tf
variable "environments" {
type = set(string)
description = "Stable environment keys used to create manifest instances."
default = ["dev", "stage"]
validation {
condition = alltrue([
for environment in var.environments :
can(regex("^[a-z][a-z0-9-]*$", environment))
])
error_message = "Each environment must use lowercase letters, numbers, or hyphens and start with a letter."
}
}
variable "owner" {
type = string
description = "Owner recorded in every generated manifest."
default = "platform-learning"
validation {
condition = length(trimspace(var.owner)) > 0
error_message = "owner must not be empty."
}
}
main.tf
locals {
metadata = {
managed_by = "terraform"
owner = var.owner
}
}
resource "random_pet" "environment" {
for_each = var.environments
prefix = each.key
length = 2
separator = "-"
}
resource "local_file" "manifest" {
for_each = random_pet.environment
filename = "${path.module}/generated/${each.key}.json"
file_permission = "0644"
directory_permission = "0755"
content = jsonencode({
codename = each.value.id
environment = each.key
metadata = local.metadata
})
lifecycle {
precondition {
condition = contains(var.environments, each.key)
error_message = "Every manifest key must come from the environments input."
}
}
}
check "one_manifest_per_environment" {
assert {
condition = length(local_file.manifest) == length(var.environments)
error_message = "Every environment must have exactly one manifest."
}
}
outputs.tf
output "manifest_paths" {
description = "Generated manifest path for each environment."
value = {
for environment, manifest in local_file.manifest :
environment => manifest.filename
}
}
output "codenames" {
description = "Stable generated codename for each environment."
value = {
for environment, pet in random_pet.environment :
environment => pet.id
}
}
tests/manifests.tftest.hcl
run "default_environments_have_manifests" {
command = plan
assert {
condition = toset(keys(output.manifest_paths)) == toset([
"dev",
"stage",
])
error_message = "Default manifest keys must be dev and stage."
}
assert {
condition = alltrue([
for path in values(output.manifest_paths) :
endswith(path, ".json")
])
error_message = "Every manifest path must end in .json."
}
}
run "custom_environment_changes_keyspace" {
command = plan
variables {
environments = ["prod"]
owner = "release-engineering"
}
assert {
condition = toset(keys(output.manifest_paths)) == toset(["prod"])
error_message = "Custom environments must define the resource keyspace."
}
}
Act A — initialize and inspect dependency selection
terraform init
terraform providers
git status --short
sed -n '1,220p' .terraform.lock.hcl
If a teammate clones the project with the lock file and runs terraform init, will Terraform simply choose the newest provider versions matching ~>?
Normally it reuses the locked selections if they still satisfy the constraints. terraform init -upgrade deliberately asks Terraform to reconsider newer allowed versions.
Act B — format, validate, and test
terraform fmt -recursive
terraform validate
terraform test
Introduce an invalid environment temporarily ("Production!") to see input validation stop planning with your actionable message. Then restore ["dev", "stage"].
Act C — plan before creating
terraform plan -out=first.tfplan
terraform show first.tfplan
Predict the addresses: random_pet.environment["dev"], random_pet.environment["stage"], local_file.manifest["dev"], local_file.manifest["stage"]. The keys and filenames are known; the generated codenames and therefore final file content are not.
Act D — apply the exact plan
terraform apply first.tfplan
terraform output
terraform state list
terraform state show 'random_pet.environment["dev"]'
Act E — expand a keyspace
Change default = ["dev", "stage", "prod"].
How many new instances?
Two: + random_pet.environment["prod"] and + local_file.manifest["prod"]. Existing dev and stage addresses remain. Reorder to ["prod", "dev", "stage"] — because the variable is a set, order is not identity. Expect no changes.
Act F — remove one stable key
Change default = ["dev", "prod"]. Only ["stage"] is destroyed. Nothing turns prod into stage. This is the practical difference between stable for_each keys and positional count indexes.
Act G — create and repair drift
printf '{"edited":"outside terraform"}\n' > generated/dev.json
terraform plan
terraform apply
The local provider reads the actual file and detects that it no longer matches the managed content. Terraform restores the desired content. Delete a generated file and plan — the file remains declared at a tracked address, so Terraform proposes creating it again. This is drift correction when Terraform runs. No background Terraform process recreated the file at deletion time.
Act H — observe the danger of a rename, then fix it
Rename the resource block to random_pet "codename" (without a moved block). Plan shows destroy for random_pet.environment["dev"] and create for random_pet.codename["dev"]. Do not apply. Add:
moved {
from = random_pet.environment
to = random_pet.codename
}
The existing generated identities move to the new addresses instead of being recreated. This is the opening puzzle resolved with real state.
Act I — import an existing identity
resource "random_uuid" "adopted" {}
import {
to = random_uuid.adopted
id = "d9428888-122b-4f20-8f16-ff5e7c0b6ce0"
}
Plan and apply. The import block did not generate the UUID — it declared that this existing identity belongs at that address.
Act J — relinquish ownership without destroy
removed {
from = random_uuid.adopted
lifecycle {
destroy = false
}
}
Terraform proposes forgetting the address without destroying an underlying object. random_uuid.adopted is no longer tracked.
Act K — examine state carefully
terraform state list
terraform state show 'random_pet.codename["dev"]'
terraform state pull # raw state — treat as sensitive!
Act L — rerun tests after refactoring
terraform fmt -recursive
terraform validate
terraform test
terraform plan
Tests still pass because they assert the public outputs rather than the old internal resource label.
Act M — destroy deliberately
terraform plan -destroy
terraform destroy
terraform state list
find generated -type f -maxdepth 1 -print
Remove disposable local artifacts only after confirming destruction.
Workshop debrief
| Event | Why Terraform did it |
|---|---|
| First plan created four instances | Two known environment keys across two resource blocks |
| Codenames were unknown | Provider generates them during create |
| Second plan was empty | Configuration, state, and files agreed |
| Adding prod left dev/stage alone | for_each keys preserve identity |
| Reordering did nothing | Set membership, not position, was identity |
| Manual edit caused a diff | Provider refresh observed file drift |
| Rename planned replacements | Resource address changed |
moved prevented replacement | State associations were relocated before planning |
| Import tracked a fixed UUID | Existing identity was associated with an address |
removed forgot the UUID | State ownership ended without destroy |
| Tests survived internal rename | They asserted output contract |
Operator's Pocket Guide
Everyday loop
terraform fmt -recursive
terraform validate
terraform test
terraform plan
terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan
Initialize and upgrade
terraform init
terraform init -backend=false
terraform init -upgrade
terraform providers
terraform modules
Inspect
terraform version
terraform workspace show
terraform state list
terraform state show 'ADDRESS'
terraform output
terraform show
terraform show tfplan
terraform graph
Special planning modes
terraform plan -destroy
terraform plan -refresh-only
terraform plan -replace='ADDRESS'
terraform plan -detailed-exitcode
State/refactor operations — use deliberately
terraform state mv 'OLD' 'NEW'
terraform state rm 'ADDRESS'
terraform import 'ADDRESS' 'PROVIDER_ID'
terraform force-unlock 'LOCK_ID'
Prefer moved, import, and removed configuration blocks when suitable.
Cleanup
terraform plan -destroy
terraform destroy
Plan-Review Decision Tree
flowchart TD
P["Read the plan"] --> W{"Correct backend,\nworkspace, account, region?"}
W -->|No / unsure| STOP["STOP"]
W -->|Yes| D{"Any destroy or replacement?"}
D -->|Yes| ID{"Expected identity change?"}
ID -->|No| MOVE["Check address rename,\nfor_each keys, imports, moved blocks"]
ID -->|Yes| DATA{"Stateful or shared object?"}
DATA -->|Yes| MIG["Require backup, migration,\ndowntime and rollback review"]
DATA -->|No| DOWN["Check dependency impact\nand availability strategy"]
D -->|No| U{"In-place updates safe?"}
U -->|No / unclear| DOC["Read provider docs/changelog\nand system impact"]
U -->|Yes| K{"Unknown values or deferred reads?"}
K -->|Yes| TRACE["Trace graph and apply-time risk"]
K -->|No| OUT{"Outputs/policies/cost acceptable?"}
TRACE --> OUT
MIG --> OUT
DOWN --> OUT
DOC --> OUT
OUT -->|No| STOP
OUT -->|Yes| FRESH{"Final saved plan fresh\nand approved?"}
FRESH -->|No| STOP
FRESH -->|Yes| APPLY["Apply exact plan\nthen verify"]
Misconceptions to Remove Permanently
| Misconception | Reality |
|---|---|
"Terraform compares .tf files with the cloud" | It also needs state to know which provider object belongs to which address. |
| "The resource label is just a variable name" | It is part of durable identity. |
| "Plan is dry-run apply" | It is a provider-informed proposal based on current observations. APIs can still behave differently during apply. |
| "Sensitive means not stored" | Sensitive normally means redacted from display. Use ephemeral/write-only to avoid persistence. |
| "A module has its own state" | Child module resources share the root run's state unless called by a separate root configuration. |
| "Workspaces are environments" | CLI workspaces are multiple state instances for one directory. They are not automatically strong credential, backend, or access boundaries. |
"depends_on fixes ordering" | It models a dependency. It cannot fix cycles, API readiness, or application retries. |
"create_before_destroy guarantees zero downtime" | It changes replacement order where possible. The system still needs coexistence, health checks, and traffic shifting. |
| "State is safe because it is just metadata" | State can contain credentials, private keys, scripts, and passwords. |
| "An apply error means Terraform rolled back" | Terraform is not a cross-provider transaction manager. Successful earlier actions may remain and be recorded. |
"-target makes large Terraform faster" | Routine targeting hides parts of the configuration. Split state along architectural boundaries instead. |
| "If an object already exists, Terraform will find it" | Existing objects require explicit data lookup or import. Terraform does not adopt by visual similarity. |
Final Mental Model
flowchart TB
subgraph CODE["CODE"]
HCL["HCL blocks + expressions"]
ADDR["stable resource addresses"]
MOD["module contracts"]
HCL --> ADDR --> MOD
end
subgraph PREP["INITIALIZATION"]
BACK["backend"]
DEPS["module/provider packages"]
LOCK["dependency lock file"]
end
subgraph THINK["PLAN"]
STATE["prior state identities"]
READ["provider refresh"]
GRAPH["dependency graph + unknown values"]
RULES["provider schema + lifecycle"]
PROPOSAL["reviewable action proposal"]
STATE --> READ --> GRAPH
RULES --> GRAPH --> PROPOSAL
end
subgraph ACT["APPLY"]
CALLS["ordered/parallel API calls"]
RESULTS["provider IDs + final attributes"]
NEWSTATE["new durable state"]
CALLS --> RESULTS --> NEWSTATE
end
CODE --> THINK
PREP --> THINK
THINK --> ACT
Terraform is a graph planner and stateful ownership system:
- Configuration declares resource instances and relationships.
- Addresses give those instances durable Terraform identity.
- State connects addresses to provider object identities.
- Providers refresh reality and define API-specific change semantics.
- Terraform builds a plan with known and unknown values.
- Humans and policy review the proposed transition.
- Apply walks the graph, calls APIs, and records successful results.
- Terraform exits; later runs observe subsequent drift.
Final Exam — Reason from the Machine
Do not answer with slogans. Narrate configuration, state, reality, provider, address, graph, and plan.
- A block label changes but every remote argument stays identical. Why might the plan replace the object, and how do you prevent it?
- A resource exists in the cloud with the desired name but no state association. Why does Terraform plan another one?
- Why can
for_each = toset(resource.example[*].id)fail before apply? - A remote autoscaler changes replicas from 3 to 8. Compare normal and refresh-only plans.
- A database password is marked sensitive. Where can it still appear?
- An apply creates a network and then fails creating a subnet. What should the next operator inspect before retrying?
- A team needs separate production credentials and approvals. Why might CLI workspaces be insufficient?
- A child module receives an aliased provider. Where should the provider requirement and provider configuration live?
- One resource is replaced during a provider upgrade despite no HCL change. How can this be legitimate, and how should it be reviewed?
- A resource should remain in the cloud but move to another management system. What transition expresses that intent?
- Why can a postcondition stop downstream actions but not roll back earlier API calls?
- When is
ignore_changesappropriate, and what ownership document should accompany it? - What does a state lock protect, and what important races remain?
- Why is applying a saved plan stronger than approving a speculative PR plan and later running plain
terraform apply? - How would you choose whether network and application resources belong in the same state?
If you can answer all fifteen from the three-world model, you are ready to operate Terraform deliberately rather than experimentally.
Official Source Trail
The concepts and current language behavior in this guide were checked against HashiCorp's official documentation:
- What Terraform is
- Core workflow
- Plan command
- State purpose and guidance
- Backends, storage, and locking
- Provider requirements
- Providers within modules
- Resources and meta-arguments
- Built-in
terraform_data - Module development and composition
- Moved blocks and refactoring
- Import blocks
- Removed blocks
- Sensitive and ephemeral values
- Validation and conditions
- Terraform tests
- CLI workspaces
local_filelab resourcerandom_petlab resource