Terraform Workflow & CLI
The init → plan → apply → destroy loop in depth, the CLI commands you'll actually use daily, how to read an execution plan safely, and how to use -target/-var/-var-file/-auto-approve without shooting yourself in the foot.
- Chapter 1: Foundations & IaC Concepts
- Explain what happens internally at each stage of init/plan/apply/destroy
- Use fmt, validate, refresh, taint/untaint, import, and state subcommands correctly
- Read a plan's +/-/~ symbols and know what each one means for real infrastructure
- Use -target, -var, -var-file, and -auto-approve responsibly, and know when each is dangerous
Terraform Workflow & CLI
1. Why does this exist?
Knowing HCL syntax doesn’t tell you how Terraform actually gets from “a file on disk” to “a running EC2 instance.” That journey — init, plan, apply, destroy — is Terraform’s core safety mechanism: it never just does what you asked; it first tells you exactly what it’s about to do, in plain terms, and waits for you to say yes. Understanding this loop, and the CLI commands that support it, is the difference between using Terraform confidently in production and being surprised by it.
2. Concept
What: The workflow is four stages — init (prepare the working directory), plan (compute the diff between desired and actual state), apply (execute that diff), destroy (a special apply that removes everything).
Why: Separating “compute the plan” from “execute the plan” means you always get a human-readable preview before anything real happens — the single most important safety property Terraform has.
When: Every single change to managed infrastructure, without exception — this loop doesn’t have a “skip plan, I know it’s fine” fast path for a reason.
Where: Locally during development; inside CI/CD for anything touching shared environments (plan on PR, apply on merge is the standard pattern).
3. Architecture Diagram
flowchart LR
A[terraform init] --> B[terraform plan]
B --> C{Plan reviewed<br/>by a human?}
C -->|Approved| D[terraform apply]
C -->|Rejected| E[Edit .tf files]
E --> B
D --> F[(State updated)]
F -.->|later| G[terraform destroy]
init only needs to run once per working directory (or whenever providers/modules/backend config change) — plan and apply are what you run repeatedly during normal work.
4. Visual Explanation
Reading a Plan
# aws_instance.web will be updated in place
~ resource "aws_instance" "web" {
id = "i-0abc123"
~ instance_type = "t3.micro" -> "t3.small"
}
# aws_security_group.web will be destroyed and re-created
-/+ resource "aws_security_group" "web" {
~ name = "web-sg" -> "web-sg-v2" # forces replacement
}
Plan: 1 to add, 1 to change, 1 to destroy.
| Symbol | Meaning |
|---|---|
+ |
Resource will be created |
- |
Resource will be destroyed |
~ |
Resource will be updated in place — no downtime for most attribute changes |
-/+ |
Resource will be destroyed, then recreated (replacement) — this often means downtime |
Hint: how do I know if a change forces replacement before I even run plan?
The plan output itself tells you — look for the forces replacement comment next to the changed attribute. Some attributes (like an EC2 instance's AMI ID) are immutable at the cloud API level, so Terraform has no choice but to destroy and recreate. Provider documentation for each resource also lists which arguments force replacement.
5. Example
terraform init
# Initializing the backend...
# Initializing provider plugins...
# Terraform has been successfully initialized!
terraform plan -out=tfplan
# Plan: 1 to add, 0 to change, 0 to destroy.
terraform apply tfplan
# aws_s3_bucket.demo: Creating...
# aws_s3_bucket.demo: Creation complete after 2s
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Saving the plan to a file (-out=tfplan) and applying that exact file guarantees apply does precisely what you reviewed — nothing can have changed in the gap between plan and apply (someone else applying a conflicting change, a provider update, etc.).
6. Production Example
A CI pipeline enforcing “plan on PR, apply on merge”:
# .github/workflows/terraform.yml (excerpt)
on: [pull_request, push]
jobs:
plan:
if: github.event_name == 'pull_request'
steps:
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform show -no-color tfplan > plan.txt
# post plan.txt as a PR comment for human review
apply:
if: github.ref == 'refs/heads/main'
steps:
- run: terraform init
- run: terraform apply -auto-approve tfplan
-auto-approve is safe here specifically because a human already reviewed the exact plan as a PR comment before merge — the checkpoint moved from “typing yes in a terminal” to “approving a pull request,” not away entirely.
7. Code Walkthrough — CLI Commands You’ll Actually Use
terraform fmt # rewrite files to canonical style — run before every commit
terraform validate # syntax + internal consistency check, no API calls
terraform init # download providers/modules, configure backend
terraform plan # preview changes
terraform apply # execute changes (prompts for confirmation unless -auto-approve)
terraform destroy # preview + execute full teardown
terraform refresh # reconcile state with real infrastructure (read-only, no plan)
terraform taint <addr> # mark a resource for forced replacement on next apply
terraform untaint <addr> # reverse a taint
terraform import <addr> <id> # bring an existing real resource under Terraform management
terraform state list # list every resource in state
terraform state show <addr> # show one resource's full attributes
terraform state mv <src> <dst> # rename/move a resource in state without destroying it
terraform state rm <addr> # remove a resource from state (does NOT destroy the real resource)
terraform workspace list # list workspaces
terraform workspace new <name> # create and switch to a new workspace
terraform workspace select <name> # switch workspaces
8. Behind the Scenes
terraform plan doesn’t guess — for every resource already in state, it calls the provider’s “read” operation to fetch current real-world attributes (this is the same mechanism refresh uses standalone), then diffs that against your configuration’s desired attributes. The result feeds into the dependency graph (Chapter 8) to determine ordering: creates and updates generally happen in dependency order, destroys happen in reverse dependency order, and Terraform parallelizes independent branches of the graph automatically (this is why apply logs can interleave — up to 10 concurrent operations by default, configurable via -parallelism).
9. Common Mistakes
- Wrong: Running
applyfrom memory, without a freshplanimmediately before it, especially after time has passed or others may have changed shared infrastructure. Why: real-world state can drift between when you last looked and now. Correct: always plan immediately before apply, or apply a saved plan file from the same session. - Wrong: Reaching for
-targetas a routine way to “speed up” applies. Why: Terraform’s own documentation flags this as producing plans that can be inconsistent with a full-configuration plan — it’s meant for exceptional recovery scenarios, not daily workflow. Correct: if apply is slow, that’s usually a signal to split the configuration (Chapter 9’s modules, Chapter 15’s state-splitting), not to routinely target. - Wrong: Using
-auto-approveoutside of CI pipelines that already have a separate human-review checkpoint. Why: it removes the one safety gate Terraform has by default. Correct: reserve it for automation where a plan was already reviewed as an artifact (like the PR-comment pattern above).
10. Production Tips
- 🚀 Best Practice: Always run
terraform fmt -checkandterraform validatein CI beforeplan— catch style and syntax issues before they cost a plan cycle. - 💡 Tip: Use
terraform plan -out=tfplan+terraform apply tfplaninstead of a bareterraform applyin any shared/CI environment — it guarantees what you reviewed is exactly what runs. - ⚠️ Warning:
terraform state rmremoves a resource from Terraform’s bookkeeping without destroying the real resource — useful for deliberately “adopting out” a resource, dangerous if run by accident (the resource becomes unmanaged, silently). - 🚀 Best Practice: Treat
importas a one-time bridge, not a routine tool — after importing, write the matching resource block carefully and re-plan until it shows zero diff, confirming your HCL accurately describes the real resource.
11. Debugging
| Symptom | Likely Cause | Fix |
|---|---|---|
Error: Saved plan is stale on apply |
Configuration or state changed since the plan file was generated | Re-run terraform plan -out=tfplan fresh, then apply the new file |
Plan shows unexpected -/+ replacement |
An attribute you changed happens to be immutable at the API level | Check provider docs for that resource/attribute; consider whether replacement (vs. a workaround) is actually acceptable |
terraform apply hangs with no output |
A provider API call is slow/rate-limited, or a remote backend lock is held by another process | Check terraform force-unlock only after confirming no other apply is genuinely running; check provider status pages for API issues |
Error: Resource already exists on apply after a fresh init |
The real resource exists but isn’t in Terraform’s state (someone created it manually, or state was lost) | Use terraform import to bring it under management instead of letting Terraform try to create a duplicate |
12. Interview Questions
Q — Why does Terraform separate plan from apply instead of just applying changes directly, the way many imperative scripts do?
Separating them creates a mandatory human checkpoint: you see the exact diff — what’s created, changed, or destroyed — before anything real happens. This is Terraform’s core safety mechanism, and it’s why CI pipelines are typically built around “plan is visible for review, apply requires a separate, deliberate trigger.”
Q — What’s the practical difference between terraform state rm and terraform destroy for a single resource?
state rm removes Terraform’s bookkeeping record only — the real resource keeps running, untouched, but Terraform no longer knows about it (useful for handing a resource off to be managed elsewhere). destroy (or a targeted destroy) actually calls the provider to delete the real resource. Confusing the two is a common, sometimes costly mistake.
Q — When, specifically, is -target an acceptable tool to reach for?
Mainly in exceptional recovery situations — e.g., one resource is stuck in a bad state blocking the rest of the plan, and you need to fix just that resource in isolation before a full plan can proceed cleanly. It’s explicitly not meant as a routine performance shortcut, since a targeted plan can miss dependent changes a full plan would have caught.
13. Hands-on Lab
Goal: practice the full state-inspection toolkit safely, using the local_file resource from Chapter 1’s lab (no cloud account needed).
- Re-create
hello.txtfrom Chapter 1’s lab (or reuse it if you kept it). - Run
terraform state list— confirm you seelocal_file.hello. - Run
terraform state show local_file.hello— inspect its full recorded attributes. - Run
terraform taint local_file.hello, thenterraform plan— observe the plan now shows a replacement even though nothing in the config changed. - Run
terraform untaint local_file.hello, thenplanagain — confirm the replacement is gone.
Expected output: step 4’s plan shows -/+ resource "local_file" "hello" with a “tainted, will be replaced” style note; step 5’s plan shows no changes.
Solution
This demonstrates that taint is purely a state-level marker — it doesn't touch the real resource or your configuration at all, it just tells the next plan "treat this as needing replacement regardless of whether attributes changed." That's exactly why untaint can cleanly reverse it with zero side effects.
14. Mini Project
Build a “plan as a PR comment” habit locally. Using the S3 bucket mini project from Chapter 1:
- Make a small change (add a
tagsargument to the bucket). - Run
terraform plan -out=tfplan | tee plan-output.txt. - Manually read
plan-output.txtand write one sentence describing, in plain English, exactly what will change and why — as if you were leaving this as a PR review comment for a teammate. - Only then run
terraform apply tfplan.
This is a small, deliberate rehearsal of the discipline real teams enforce through CI — the point isn’t the tooling, it’s building the habit of narrating a plan’s real-world consequence before executing it.
16. Summary
- The
init → plan → apply → destroyloop exists specifically to give you a reviewable preview before any real change happens. +,-,~, and-/+in plan output tell you create/destroy/update-in-place/replace — replacement is the one that often means downtime.fmt,validate,refresh,taint/untaint,import, and thestatesubcommands are the toolkit for day-to-day operation and recovery, not just the four headline commands.-targetand-auto-approveare exception-handling tools, not routine workflow shortcuts — both remove safety nets Terraform otherwise gives you by default.
17. Cheat Sheet
terraform fmt && terraform validate
terraform plan -out=tfplan
terraform apply tfplan
terraform state list
terraform state show <addr>
terraform import <addr> <cloud-id>
terraform workspace new <env>
18. Related Topics
Next: Chapter 3 covers HCL syntax itself in depth — blocks, arguments, expressions — the language you’ve been writing informally in these first two chapters. Chapter 6 goes deep on state (the thing state subcommands and import manipulate). Chapter 8 covers the dependency graph that determines the ordering you saw in apply’s parallel logs.