Skip to main content
Terraform · lesson 2 of 17
Terraform beginner Lesson 2 of 17

Terraform Workflow (init → validate → plan → apply)

What terraform init, fmt, validate, plan -out and apply each guarantee, with real output, including the errors each step catches and a stale saved plan.

By SkillByExample · Updated

terraform workflow plan apply

Every Terraform change goes through the same commands in the same order. Each one checks something the next one depends on. The examples use Terraform 1.16.5 and hashicorp/random 3.9.1, which runs entirely on your machine, and this main.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

resource "random_pet" "name" {
  prefix = "web"
}

resource "random_integer" "port" {
  min = 8000
  max = 8999
}

output "endpoint" {
  value = "${random_pet.name.id}:${random_integer.port.result}"
}

init: nothing works without it

Here’s what happens if you plan in a fresh directory before running init, and then what init itself prints.

$ terraform plan -no-color

Error: Inconsistent dependency lock file

The following dependency selections recorded in the lock file are
inconsistent with the current configuration:
  - provider registry.terraform.io/hashicorp/random: required by this configuration but no version is selected

To make the initial dependency selections that will initialize the dependency
lock file, run:
  terraform init
$ terraform init -no-color
Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.9"...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/random v3.9.1 (signed by HashiCorp)

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

The configuration only names the provider and a version constraint (~> 3.9 means 3.9 or any later 3.x). init resolves that to a real release, 3.9.1, and checks its signature. It saves the binary in .terraform/ (don’t commit this) and records the choice in .terraform.lock.hcl (commit this). Run init again whenever you add a provider, a module or a backend.

fmt and validate: cheap checks before anything runs

Introduce two mistakes: write min=8000 with no spaces, and misspell random_pet.name as random_pet.nmae in the output.

$ terraform fmt
main.tf
$ terraform validate -no-color

Error: Reference to undeclared resource

  on main.tf line 22, in output "endpoint":
  22:   value = "${random_pet.nmae.id}:${random_integer.port.result}"

A managed resource "random_pet" "nmae" has not been declared in the root
module.

fmt fixed the spacing without saying how. It only prints the names of the files it rewrote. validate caught the typo using nothing but the .tf files. It doesn’t read state or call any API, so it finishes in a fraction of a second (about 0.1s here) and needs no credentials. That’s why CI runs it first. Fix the typo and it reports Success! The configuration is valid.

plan -out, then apply the saved plan

$ terraform plan -out=tfplan -no-color

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # random_integer.port will be created
  + resource "random_integer" "port" {
      + id     = (known after apply)
      + max    = 8999
      + min    = 8000
      + result = (known after apply)
    }

  # random_pet.name will be created
  + resource "random_pet" "name" {
      + id        = (known after apply)
      + length    = 2
      + prefix    = "web"
      + separator = "-"
    }

Plan: 2 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + endpoint = (known after apply)

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"
$ terraform apply -no-color tfplan
random_pet.name: Creating...
random_integer.port: Creating...
random_pet.name: Creation complete after 0s [id=web-upright-longhorn]
random_integer.port: Creation complete after 0s [id=8612]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

endpoint = "web-upright-longhorn:8612"

apply tfplan didn’t print the plan again or ask for confirmation. Saving the plan was the review step, so applying the file just carries it out. The two resources don’t reference each other, so they were created in parallel, and the order of the Creating... lines can differ between runs.

A saved plan goes stale

Apply the same tfplan file a second time:

$ terraform apply -no-color tfplan

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

A saved plan records the state it was computed against. Here the first apply changed the state, so the plan no longer describes a valid transition and Terraform refuses it. The same thing happens when a teammate applies something between your plan and your apply. You can’t accidentally apply a reviewed plan on top of changes nobody reviewed.

Practice

1. Delete the max = 8999 line and run terraform validate. What does the error name?

Solution
$ terraform validate -no-color

Error: Missing required argument

  on main.tf line 16, in resource "random_integer" "port":
  16: resource "random_integer" "port" {

The argument "max" is required, but no definition was found.

validate knows each resource’s schema from the provider binary init downloaded, so a missing required argument is caught without any plan.

2. With the resources applied, change max to 9999, save a plan, and inspect it with terraform show tfplan instead of applying it.

Solution
$ terraform show -no-color tfplan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # random_integer.port must be replaced
-/+ resource "random_integer" "port" {
      ~ id     = "8612" -> (known after apply)
      ~ max    = 8999 -> 9999 # forces replacement
      ~ result = 8612 -> (known after apply)
        # (1 unchanged attribute hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  ~ endpoint = "web-upright-longhorn:8612" -> (known after apply)

A plan file is binary. terraform show turns it back into the same text plan printed, so a reviewer can read it later, for example after CI saved it as a build artifact.

3. Put the original max back. Then destroy everything through a saved plan instead of terraform destroy.

Solution
$ terraform plan -destroy -out=destroy.tfplan -no-color | tail -4
Saved the plan to: destroy.tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "destroy.tfplan"
$ terraform apply -no-color destroy.tfplan
random_integer.port: Destroying... [id=8612]
random_pet.name: Destroying... [id=web-upright-longhorn]
random_integer.port: Destruction complete after 0s
random_pet.name: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 2 destroyed.

terraform destroy is shorthand for plan -destroy followed by apply. Splitting it gives a destroy the same review step as any other change.

Next: Terraform state, the file that made “Saved plan is stale” possible, and the commands for inspecting it.

Frequently Asked Questions

Why does Terraform need terraform init?
init downloads the providers and modules the configuration requires, configures the state backend, and writes .terraform.lock.hcl recording the exact provider versions and checksums chosen. Until it has run, plan refuses to start with an 'Inconsistent dependency lock file' error, because it has no provider to talk to.
What's the difference between terraform validate and terraform plan?
validate checks the configuration on its own: syntax, references to things that exist, required arguments and types. It never reads state or contacts an API. plan does all of that and also refreshes state against real infrastructure to compute the actual changes, so it can fail for reasons validate cannot see, like expired credentials.
Why save a plan with -out instead of just running apply?
terraform apply on its own computes a fresh plan, and that plan can differ from the one you reviewed if anything changed in between. terraform apply tfplan carries out exactly the saved plan without asking again, and refuses with 'Saved plan is stale' if the state has changed since the plan was made.
Should .terraform.lock.hcl be committed to git?
Yes. It records the exact provider versions and checksums init selected, so every teammate and CI run installs the same provider builds. The .terraform directory next to it holds the downloaded binaries and should be gitignored.
What does terraform fmt change?
It rewrites .tf files into the canonical HashiCorp style: two-space indentation, aligned equals signs within a block and normalised spacing. It prints the name of each file it changed and changes nothing about what the configuration means, so it is safe to run on every save.