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.
| 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 downDownloads 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/.
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.
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.
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 }.
Neither touches infrastructure, and both belong in CI:
terraform fmt -recursive # canonical formatting
terraform validate # syntax and internal consistencyfmt 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.
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 tfplanWithout -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.
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.
| 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 |
| 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 |
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.
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.
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.
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.
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.
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.
Learn what terraform init does: download providers, configure the backend, write the lock file, use -upgrade and -reconfigure and run it in CI/CD.
Read terraform plan symbols correctly, save a plan file with -out, target resources, detect drift and run plan in CI/CD without surprises on apply.
Use terraform apply safely: review the plan, apply a saved plan file, tune parallelism, use -auto-approve in CI/CD and recover from common apply errors.