Learning PathsTerraform
TerraformChapter 2·BEGINNER·11 min read

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.

Prerequisites
  • Chapter 1: Foundations & IaC Concepts
You'll learn to
  • 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 apply from memory, without a fresh plan immediately 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 -target as 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-approve outside 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 -check and terraform validate in CI before plan — catch style and syntax issues before they cost a plan cycle.
  • 💡 Tip: Use terraform plan -out=tfplan + terraform apply tfplan instead of a bare terraform apply in any shared/CI environment — it guarantees what you reviewed is exactly what runs.
  • ⚠️ Warning: terraform state rm removes 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 import as 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).

  1. Re-create hello.txt from Chapter 1’s lab (or reuse it if you kept it).
  2. Run terraform state list — confirm you see local_file.hello.
  3. Run terraform state show local_file.hello — inspect its full recorded attributes.
  4. Run terraform taint local_file.hello, then terraform plan — observe the plan now shows a replacement even though nothing in the config changed.
  5. Run terraform untaint local_file.hello, then plan again — 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:

  1. Make a small change (add a tags argument to the bucket).
  2. Run terraform plan -out=tfplan | tee plan-output.txt.
  3. Manually read plan-output.txt and 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.
  4. 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 → destroy loop 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 the state subcommands are the toolkit for day-to-day operation and recovery, not just the four headline commands.
  • -target and -auto-approve are 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>

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.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
← Foundations & IaC ConceptsConfiguration Language (HCL) Basics →