The Terraform Core Workflow
The init, plan, apply, destroy loop. What each command does, how to read a plan, and the mistakes that cause outages.
Every Terraform project, from a single S3 bucket to a multi-account platform, runs the same four-command loop. Learning what each command actually does, rather than treating them as magic words, is what makes the rest of Terraform predictable.
The four commands
| Command | What it does | Changes infrastructure |
|---|---|---|
terraform init | Downloads providers and modules, configures the backend | No |
terraform plan | Compares config, state and reality; prints the diff | No |
terraform apply | Executes the changes | Yes |
terraform destroy | Removes everything in state | Yes |
The loop is write → init → plan → apply, repeated for the life of the project. destroy sits outside it and is used deliberately.
terraform init # once per project, and after provider/backend changes
terraform plan # every time, before applying
terraform apply # after reading the plan
terraform destroy # when tearing an environment down
What actually happens at each step
init
Downloads provider plugins into .terraform/, fetches external modules, configures where state lives, and writes .terraform.lock.hcl pinning exact provider versions.
Nothing else works until it has run, without providers loaded, Terraform does not know what an aws_instance is. Re-run it after adding a provider, changing a version constraint, adding a module, or changing the backend. Terraform tells you when it is needed.
Commit .terraform.lock.hcl; gitignore .terraform/.
plan
This is the step that distinguishes Terraform from clicking in a console. It refreshes state against the real world, compares that to your configuration, and prints exactly what it would do.
Terraform will perform the following actions:
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123"
~ instance_type = "t3.small" -> "t3.medium"
}
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ engine_version = "15.4" -> "16.2" # forces replacement
}
Plan: 1 to add, 1 to change, 1 to destroy.
Four symbols, and one of them matters far more than the others:
| Symbol | Meaning |
|---|---|
+ | Create |
~ | Update in place (safe) |
- | Destroy |
-/+ | Replace (destroy then create) |
-/+ is the one to read every time. It means the attribute you changed cannot be modified in place, so Terraform will delete the resource and build a new one. On a stateless resource that is fine. On a database, a volume, or anything holding data, it is data loss arriving through what looked like a routine change. Terraform annotates the responsible attribute with # forces replacement, so the cause is always stated.
The summary line, 1 to add, 1 to change, 1 to destroy, is the fastest sanity check that you are pointed at the environment you think you are.
apply
Builds a dependency graph, executes changes in order, runs independent resources in parallel, and writes state as it goes.
If it fails partway, Terraform does not roll back. Resources already created are kept and recorded, because destroying successfully created infrastructure is usually worse. Fix the cause and apply again. Terraform reconciles from where it is.
destroy
Removes every resource in state for that configuration and workspace. Preview it first with terraform plan -destroy, and protect anything important with lifecycle { prevent_destroy = true }.
Two commands that belong in the loop
Neither touches infrastructure, and both belong in CI:
terraform fmt -recursive # canonical formatting
terraform validate # syntax and internal consistency
fmt ends formatting debates by having one right answer. validate catches typos, wrong argument names and type errors without contacting a provider, so it fails in seconds rather than at plan time.
Run fmt -check in CI so unformatted code cannot merge.
How the workflow changes in a team
Working alone, the loop above is enough. The moment a second person or a pipeline is involved, three things must change.
State moves to a remote backend with locking. Local state cannot be shared, has no version history, and two simultaneous applies will corrupt it. See Remote Backends.
Plan and apply become separate, reviewed steps. The pipeline plans on a pull request and posts the output for review, then applies on merge. Critically, it applies a saved plan file:
terraform plan -out=tfplan -input=false
# reviewed and approved
terraform apply tfplan
Without -out, the apply recalculates and may not match what was reviewed. With it, the approved change is the applied change.
Environments get separate state. Production and staging in one state file means one mistake reaches both.
The mistakes that cause real problems
Applying without reading the plan. Particularly missing a -/+ on something stateful.
Editing infrastructure in the console. Terraform will plan to undo it on the next run. If a change must be made by hand, bring it back into configuration promptly or Terraform becomes untrustworthy, and a tool people distrust gets bypassed, which compounds.
Committing state or .terraform/. State holds secrets in plain text; .terraform/ holds large binaries.
Using -target routinely. It is a recovery tool. Habitual use means part of your configuration is never validated against reality.
Not pinning provider versions. An unpinned provider can change behaviour between runs. Use required_providers with constraints and commit the lock file.
Explore each command
| Command | What you will learn |
|---|---|
| terraform init | Provider downloads, backend setup, the lock file |
| terraform plan | Reading plans, saved plan files, drift, targeting |
| terraform apply | Parallelism, auto-approve, handling failures |
| terraform destroy | Safe teardown, partial destroys, fmt and validate |
Quick reference
| Task | Command |
|---|---|
| Initialise | terraform init |
| Upgrade providers | terraform init -upgrade |
| Preview changes | terraform plan |
| Save a plan | terraform plan -out=tfplan |
| Apply a saved plan | terraform apply tfplan |
| Preview a teardown | terraform plan -destroy |
| Format | terraform fmt -recursive |
| Validate | terraform validate |
| Read outputs | terraform output |
| Evaluate an expression | terraform console |
Final Thoughts
The loop is four commands, but the discipline is one habit: read the plan before every apply. Almost every Terraform incident is a plan somebody scrolled past: a -/+ on a database, a destroy count larger than expected, or an apply against the wrong workspace. The plan tells you all three before anything happens.
Frequently Asked Questions
What is the correct order of Terraform commands?
terraform init first, once per project and again whenever providers, modules or the backend change. Then terraform plan to see what would happen, and terraform apply to execute it. terraform destroy tears everything down when an environment is no longer needed. In practice you also run terraform fmt and terraform validate before planning, and both belong in CI so unformatted or invalid configuration cannot merge.
Do I need to run terraform plan before apply?
terraform apply produces a plan and asks for confirmation, so technically no. You should anyway, and in a team you must. The plan is the only place Terraform tells you that a change will replace a resource rather than update it, which on a database means data loss. In CI, run plan -out=tfplan and apply that file, which guarantees the change reviewed is the change applied.
What does the -/+ symbol mean in a Terraform plan?
Replace: Terraform will destroy the existing resource and create a new one, because the attribute you changed cannot be modified in place. The plan annotates the responsible attribute with # forces replacement. This is the single most important thing to look for, since on stateless resources it is harmless and on anything storing data it is destructive. Protect critical resources with lifecycle { prevent_destroy = true } so a replacement cannot be applied by accident.
What happens if terraform apply fails halfway through?
Terraform stops and keeps whatever it successfully created, recording it in state. There is no rollback, because destroying working infrastructure is usually worse than leaving it. Fix the underlying problem and run apply again; Terraform reconciles from the current state and creates only what is still missing. If the process was killed rather than erroring, a state lock may remain and need clearing with terraform force-unlock.
How often do I need to run terraform init?
Once when you start, and again after anything it configures changes. Adding or upgrading a provider, adding or changing a module source, or modifying the backend. You also need it after cloning a repository, since .terraform/ is not committed. Terraform detects when initialisation is stale and refuses to proceed with a clear message, so you rarely have to remember.
What is the difference between terraform fmt and terraform validate?
fmt rewrites files into canonical formatting, indentation and alignment, and changes nothing about behaviour. validate checks that the configuration is syntactically valid and internally consistent: correct argument names, sensible types, references that resolve. Neither contacts a provider or touches infrastructure, both run in under a second, and both belong in CI, with fmt -check failing the build on unformatted code.