Plan & Apply
Terraform’s execution model is built around a core data structure - the Directed Acyclic Graph (DAG) - that maps every dependency relationship in your configuration and drives the correct ordering of every create, update, and destroy operation.
Directed Acyclic Graphs (DAGs)
Section titled “Directed Acyclic Graphs (DAGs)”What is a DAG?
Section titled “What is a DAG?”A graph is a collection of nodes (resources) and edges (connections between them). Terraform’s version is:
- Directed - edges have a direction that defines who depends on whom
- Acyclic - no circular loops are allowed
This structure is called the resource graph internally and is Terraform’s central data structure for all planning and apply operations.
Node Types
Section titled “Node Types”Terraform’s resource graph contains three types of nodes:
| Node Type | Description |
|---|---|
| Resource node | Maps to a specific resource, data source, or individual iteration from count/for_each |
| Provider configuration node | One node per provider configuration (e.g. two AWS provider configs → two nodes) |
| Resource meta node | Groups resources when count > 1 - exists mainly to keep graph diagrams clean |
Visualising the Graph
Section titled “Visualising the Graph”The terraform graph command outputs a text representation of your dependency graph in GraphViz .dot format. Pipe it through the dot CLI to produce images:
# Generate a PNG diagramterraform graph | dot -Tpng > graph.png
# Generate an SVGterraform graph | dot -Tsvg > graph.svg
# Save the raw .dot fileterraform graph > graph.dotThe -type flag selects which phase to visualise:
| Mode | What it shows |
|---|---|
plan | Simplified dependency view |
plan-refresh-only | Refresh-only plan dependencies |
plan-destroy | Destroy plan order |
apply | Full resource-level apply graph (more detail than plan) |
Planning Modes
Section titled “Planning Modes”Running terraform plan compares your configuration against the current state and produces an execution plan. Terraform offers four planning modes:
| Mode | Command | What it does |
|---|---|---|
| Default | terraform plan | Compares code + state → generates a change plan |
| Destroy | terraform plan -destroy | Plans to tear down all managed infrastructure. Destruction happens in the reverse of creation order. |
| Refresh-only | terraform plan -refresh-only | Updates local state to reflect real-world infrastructure - no infrastructure changes are made |
| Targeted | terraform plan -target=<address> | Plans only the specified resource(s). ⚠️ Anti-pattern - use only for exceptional debugging. |
Reading Plan Output
Section titled “Reading Plan Output”In standard plan output, resources are shown bottom-to-top:
- Bottom - resources with no dependencies (created first)
- Top - resources that depend on those below them
Forcing Replacement (-replace)
Section titled “Forcing Replacement (-replace)”-replace supersedes the deprecated terraform taint command. It flags a specific resource for forced destroy + re-create without immediately modifying state, so you can review the plan before committing:
terraform plan -replace="aws_instance.web"Saving a Plan (-out)
Section titled “Saving a Plan (-out)”Save a plan to a binary file for later review and guaranteed-unchanged apply:
terraform plan -out=plan.tfplan
# Make the binary human-readableterraform show plan.tfplan
# Convert to JSON for programmatic processingterraform show -json plan.tfplanRoot Module Input Variables
Section titled “Root Module Input Variables”Input variables let you use the same root module configuration to generate different environments (staging vs. production, multi-tenant, etc.).
Assignment Methods
Section titled “Assignment Methods”| Method | How | Notes |
|---|---|---|
-var flag | terraform plan -var 'count=3' | Highest precedence. Struggles with complex types (objects, maps) and has cross-shell escaping issues. |
-var-file flag | terraform plan -var-file=staging.tfvars | Explicit file load. Best for complex types. |
Auto-loaded .tfvars | Named terraform.tfvars, terraform.tfvars.json, or *.auto.tfvars | Loaded automatically without a flag. |
| Environment variables | export TF_VAR_instance_count=2 | Case-sensitive variable names. Commonly used by CI/CD systems for auth credentials. |
| Interactive prompt | Terraform asks at runtime | Only triggered if all other methods fail and no default is set. |
Variable Precedence (Highest → Lowest)
Section titled “Variable Precedence (Highest → Lowest)”-varand-var-fileCLI flags (last flag wins if specified multiple times)*.auto.tfvarsand*.auto.tfvars.json(processed in lexical/alphabetical order)terraform.tfvars.jsonterraform.tfvarsTF_VAR_environment variables- Interactive prompt (only if nothing else is set)
Managing Secrets
Section titled “Managing Secrets”Prefer these patterns instead:
| Approach | How |
|---|---|
| CI/CD secret managers | GitHub Actions, Terraform Cloud, and Spacelift all support encrypted secrets that are masked in logs |
| External secret managers | HashiCorp Vault, AWS Secrets Manager, Azure Key Vault - pass the path to the secret rather than the plaintext value; let the orchestrator inject it directly into the application |
The apply phase executes the resource graph produced during planning - creating, modifying, or deleting real-world infrastructure to match your configuration.
Method 1: Apply from a Saved Plan (Recommended)
Section titled “Method 1: Apply from a Saved Plan (Recommended)”terraform plan -out=plan.tfplanterraform apply plan.tfplan- Separates planning and execution - a human or automated tool can review the exact changes before they run
- No confirmation prompt - Terraform assumes the saved plan was already reviewed
- File extension is convention only (
.tfplan); any extension works
Method 2: Combined Plan + Apply
Section titled “Method 2: Combined Plan + Apply”terraform apply- Generates a fresh plan automatically, then pauses for interactive confirmation
- Accepts all the same flags as
terraform plan(-var,-var-file,-refresh-only, etc.)
-auto-approve
Section titled “-auto-approve”terraform apply -auto-approveSkips the confirmation prompt entirely.
Destroying Infrastructure
Section titled “Destroying Infrastructure”terraform destroyterraform destroy is an alias for terraform apply -destroy. It generates a destroy plan and asks for confirmation before executing.
Apply Options
Section titled “Apply Options”Parallelism
Section titled “Parallelism”terraform apply -parallelism=5Controls how many resource operations run simultaneously (default: 10). Execution is constrained by the dependency graph and provider API rate limits.
State Locking
Section titled “State Locking”By default Terraform locks the state file during apply to prevent concurrent runs from corrupting data:
terraform apply -lock=false # Disable lockingThe Refresh Phase
Section titled “The Refresh Phase”Before generating a plan, Terraform optionally refreshes its view of real-world infrastructure by querying each provider:
terraform plan -refresh-only # Update state without changing infrastructureterraform apply -refresh-only # Apply the state resyncrefresh-only is particularly useful for recovering from failed state updates - situations where Terraform successfully changed infrastructure but crashed before writing the result to state.
| Scenario | Recovery |
|---|---|
| Resources updated or deleted externally | Run terraform apply -refresh-only to resync state |
| New resources created but missing from state | Manually import the resources, or delete them from the cloud and re-run apply |
Resource Targeting
Section titled “Resource Targeting”terraform plan -target="aws_instance.web"terraform apply -target="module.vpc"Targeting restricts the plan/apply to a specific resource or module. Terraform automatically includes all dependencies of the targeted resource.
Common Pitfalls and Errors
Section titled “Common Pitfalls and Errors”1 · Circular Dependencies
Section titled “1 · Circular Dependencies”Symptom: Terraform fails with a Cycle error listing the resources involved.
Cause: Two or more resources depend on each other in a loop, violating the acyclic rule.
Error: Cycle: aws_security_group.app, aws_instance.webFix: Break the chain - find the shared attribute causing the loop and pass its value through an input variable or local instead of a direct resource reference. Complex circular dependencies usually signal that the underlying architecture needs to be simplified.
2 · Cascading Changes
Section titled “2 · Cascading Changes”Symptom: Modifying one resource causes Terraform to plan the forced replacement of many downstream resources.
Cause: Changing a resource attribute triggers a destroy + re-create, which propagates to all dependents.
Fix: Review the plan carefully for “forces replacement” warnings. If you want to apply changes to new resources without destroying existing dependents, add the triggering attribute to the ignore_changes lifecycle block:
lifecycle { ignore_changes = [ami] # ignore AMI updates that would force replacement}3 · Hidden Dependencies
Section titled “3 · Hidden Dependencies”Symptom: A resource fails to create because a prerequisite hasn’t launched yet, even though the plan appeared valid.
Cause: The dependency exists at the infrastructure level but isn’t expressed in Terraform code (no attribute cross-reference).
Fix: Add an explicit depends_on:
resource "aws_nat_gateway" "main" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public.id depends_on = [aws_internet_gateway.main]}4 · Always-Detected Changes (State Drift / Eternal Drift)
Section titled “4 · Always-Detected Changes (State Drift / Eternal Drift)”Symptom: Every terraform plan shows the same change even though nothing in the configuration changed.
Cause: A provider-level mismatch between how you write a value and how the provider’s API returns it - common examples: integer 45 vs float 45.0, TRUE vs true, list order differences.
Fix: Match your configuration exactly to the format the provider API returns. Check the provider docs or run a terraform show after apply to see the canonical format.
5 · Calculated Iterations (count / for_each)
Section titled “5 · Calculated Iterations (count / for_each)”Symptom: Terraform errors with “The ‘count’/‘for_each’ value depends on resource attributes that cannot be determined until apply.”
Cause: The value passed to count or for_each is only computed after resource creation (e.g. a cloud-assigned IP or resource ID) - Terraform cannot build the graph nodes at plan time.
Fix: Refactor to base iterators on values known at plan time - use input variables or locals. If stuck with for_each, fall back to count + count.index to avoid relying on retrieved values.
6 · Failed State Updates
Section titled “6 · Failed State Updates”Symptom: Infrastructure was successfully changed but Terraform crashed or lost connectivity before writing to the state file.
| Resource type | Recovery |
|---|---|
| Updated or deleted resources | Run terraform apply -refresh-only to detect and record the actual state |
| Newly created resources | Run terraform import to bring them into state, or manually delete them from the cloud provider and re-apply |