SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Core Terraform workflow

Terraform init, validate, and plan: the core workflow explained

16 min readTerraform 004 · Core Terraform workflowUpdated

The core Terraform workflow is Write, Plan, Apply: you write configuration in HCL, preview the changes Terraform intends to make with terraform plan, then execute them with terraform apply. Before either of those can run, terraform init prepares the working directory — configuring the backend, installing provider plugins and modules, and writing the dependency lock file — and terraform validate gives you a fast check that the configuration is syntactically valid and internally consistent without contacting any remote API. To use the workflow well you need to know exactly what each command does and does not do: which changes require re-running init, how the lock file and init -upgrade control provider versions, which init flags handle a backend change, what validate cannot catch, what plan reads and locks, how its exit codes behave in automation, what the +, ~, -, -/+ and +/- symbols mean, and how -out saves a plan so a later apply executes exactly what you reviewed. This lesson covers init, validate, and plan in depth; apply, destroy, and fmt follow in the next lesson.

What you’ll learn
  • Describe the Write, Plan, Apply workflow and map each stage to a Terraform command
  • Explain what terraform init installs and configures, including providers, modules, the backend, and the .terraform.lock.hcl file, and which changes require re-running it
  • Control provider and module versions with the lock file, version constraints, and terraform init -upgrade
  • Choose the right init flags (-backend-config, -migrate-state, -force-copy, -reconfigure, -backend=false) for backend scenarios
  • Use terraform validate to check syntax and internal consistency, and contrast it with fmt -check and plan as CI gates
  • Generate an execution plan with terraform plan, use its modes and options, and interpret its exit codes and the +, ~, -, -/+, +/- and <= symbols
  • Save a plan with -out and explain why applying a saved plan file guarantees the reviewed changes

The Write, Plan, Apply workflow

The core Terraform workflow has three stages: Write configuration as code, Plan to preview the changes Terraform will make, and Apply to execute them. This loop is deliberate: because your infrastructure is declared in files, every change can be reviewed as a diff before it touches real resources. You repeat the loop for every change — edit the configuration, plan, review, apply — whether you work alone on a laptop or in a team with version control and CI, where the plan is typically posted on a pull request and applied after approval.

Two supporting commands wrap the loop. terraform init is a setup step — run once per working directory and repeated only when its dependencies change — that must succeed before validate, plan or apply can run. terraform validate is a quick correctness check you can run at any point after init, typically as an early gate in a CI pipeline. Neither of these creates, changes or destroys infrastructure.

StageCommandWhat it doesTouches infrastructure?
Set upterraform initConfigures the backend, installs providers and modules, writes the lock fileNo (backend and registry access only)
Checkterraform validateVerifies syntax and internal consistencyNo
Planterraform planCompares configuration to state and proposes actionsReads only (refresh)
Applyterraform applyExecutes the plan and updates stateYes

Deciding which command comes next follows from this table. A fresh clone or a new dependency needs init; a configuration you want checked cheaply needs validate; a change you want to preview needs plan; and only apply makes it real. Editing an existing resource argument needs no init at all — go straight to plan.

terraform init: initializing a working directory

terraform init initializes a working directory containing Terraform configuration, and it is the first command you run in any new or freshly cloned configuration. Until init succeeds, terraform validate, plan and apply fail, because the provider plugins they need have not been installed. Running init performs four jobs: it configures the backend where state will be stored, downloads the provider plugins the configuration requires, installs any modules the configuration calls, and creates or updates the dependency lock file.

$ terraform init

Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.98.0...
- Installed hashicorp/aws v5.98.0 (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.

Terraform has been initialized successfully!

What init does not do matters as much. It creates no infrastructure, does not refresh or query your real infrastructure (it contacts only the backend and provider/module registries), and does not format or rewrite your configuration. With the default local backend it does not even create a state file: terraform.tfstate first appears when an apply records resources. Init is safe to run repeatedly — rerunning it on an already-initialized directory just re-checks dependencies.

Change you madeRe-run init?
Added a provider to required_providers (or a resource from a new provider)Yes
Added a module block, or changed a module's source or versionYes
Added or changed the backend configurationYes
Changed a provider configuration argument (for example the region in a provider block)No — plan picks it up
Edited resource arguments, variables or outputsNo

When init is needed and you skip it, plan or validate fails with an error asking you to run terraform init. Two narrower commands overlap with parts of init, and neither replaces it: terraform get downloads or updates modules only (never providers or the backend), and terraform providers lock writes provider checksums into .terraform.lock.hcl — for example for extra platforms — without installing any plugins into the working directory.

What init installs: providers, modules, and the lock file

Init downloads two kinds of dependency into the hidden .terraform directory. Provider plugins — the binaries that actually talk to AWS, Google Cloud, Azure, or utility providers like random — are installed under .terraform/providers, selected from the version constraints in your required_providers block. Modules referenced by module blocks are fetched into .terraform/modules, whether they come from the public registry, a Git URL, or a local path. The .terraform directory is machine-local and should never be committed to version control.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

Init also writes .terraform.lock.hcl, the dependency lock file. It records the exact provider version selected for each provider and its checksums, so every teammate and every CI run installs identical provider builds. Unlike .terraform, the lock file should be committed to version control.

Once a provider is recorded in the lock file, plain terraform init installs that locked version — even if a newer release also satisfies the constraint. To move forward deliberately, run terraform init -upgrade: it re-selects the newest versions that the constraints allow and rewrites the lock file, which you then commit so teammates and CI get the same release. -upgrade never leaves the constraint: with version = "~> 5.0" it can pick the newest 5.x but never 6.0. It also upgrades modules to the newest versions their version arguments allow, not just providers.

The lock file and the constraint must agree. If you tighten a constraint so that it excludes the locked version — for example the lock holds 5.40.0 and you change the constraint to ">= 5.60" — plain init fails with an error saying the locked version does not match the configured constraint, until you run terraform init -upgrade to select a matching version and rewrite the lock.

The lock file covers providers only; module versions are never recorded in it. Repeatable module selection therefore comes from the version argument on the module block itself:

Module versionWhat a fresh init or init -upgrade installs
"3.1.0" (exact)Always exactly 3.1.0 — identical module code on every machine and run
"~> 3.1" (pessimistic)The newest 3.x at or above 3.1 available at that moment — may differ between runs as new releases are published
no versionThe newest published version

The version argument applies to registry modules; Git sources pin with a ?ref= tag or commit instead.

Backend initialization and the init flags you must know

Init configures the backend — where Terraform stores state — from the backend block inside the terraform block (or the local backend by default). A backend block cannot reference input variables, locals or TF_VAR_ environment variables, so values that differ per environment or must stay out of the code are supplied at init time instead, through partial configuration with -backend-config (key=value pairs or a file). -backend-config is an init option only; plan and apply do not accept it.

$ terraform init -backend-config="bucket=my-tf-state" -backend-config="region=eu-west-1"
$ terraform init -backend-config=prod.s3.tfbackend

When you change the backend configuration, plain init detects the change and will not proceed until you state your intent. The choice depends on where the correct state lives:

FlagWhat it doesUse it when
-migrate-stateCopies existing state from the previous backend to the new one, prompting for confirmationThe old backend holds the state and the new one is empty, and you want to keep managing the same resources
-migrate-state -force-copySame migration, answering yes to the copy prompts — for non-interactive pipelinesAs above, but in automation. It still overwrites any state already in the destination
-reconfigureIgnores the saved backend configuration and initializes against the new backend; copies nothingThe new backend already holds the correct state (for example a teammate migrated it), or you deliberately want to start from what the new backend contains

Other init flags you should recognise:

  • -upgrade — upgrade providers and modules to the newest versions allowed by their constraints, updating the lock file.
  • -backend-config=... — supply partial backend configuration at init time.
  • -backend=false — skip backend initialization entirely; useful in CI jobs that have no state credentials and only need to run validate.

A typical case: you move state storage from local disk to an S3 bucket and want Terraform to keep managing the existing resources, so the state has to travel with you — that is terraform init -migrate-state. If instead the bucket already contains the up-to-date state, migrating would overwrite it with your local copy, which is why -reconfigure exists.

terraform validate: fast consistency checks without cloud access

terraform validate checks that a configuration is syntactically valid and internally consistent: HCL parses, argument names exist for the resource types used, references point at things that are declared, and types line up. It does all of this without contacting any remote service — it never reads state, never calls a cloud API, and never checks whether the values you supplied would actually be accepted by the provider's service. That makes it fast and safe to run anywhere, and it is a standard early gate in a CI pipeline.

$ terraform validate
Success! The configuration is valid.

$ terraform validate -json
{
  "format_version": "1.0",
  "valid": true,
  "error_count": 0,
  "warning_count": 0,
  "diagnostics": []
}

Validate needs the provider schemas to know which arguments are legal, so the working directory must be initialized first. In a CI job that has no backend credentials, initialize without the backend and then validate: terraform init -backend=false followed by terraform validate. The -json flag produces machine-readable output for tooling.

Keep validate's limits straight: it will catch a typo like istance_type or a reference to an undeclared variable, but it will not catch an AMI ID that does not exist, a name that collides with a real resource, missing credentials or missing IAM permissions — those surface at plan or apply time, when Terraform actually talks to the provider.

Three commands are often confused as CI gates:

CommandChecksNeeds credentials or state?
terraform fmt -checkOnly whether files are in canonical formatting — not whether the configuration is validNo (does not even need init)
terraform validateSyntax, argument names, references, typesNo — needs init for provider schemas only
terraform plan -refresh=falseBuilds a full plan against stored state without refreshingYes — it still configures the providers and reads state from the backend

terraform plan: generating an execution plan

terraform plan creates an execution plan: it reads the current state, refreshes Terraform's view of the real objects recorded there, compares that against your configuration, and proposes the actions — create, update, or destroy — needed to make reality match the code. Plan is a read-only preview: it never modifies infrastructure and never writes to the state file. Even the refresh it performs by default only updates Terraform's in-memory view for the comparison; the stored state is untouched until apply. Because it reads state, plan acquires the state lock by default on backends that support locking, so by default it fails immediately if another run holds the lock (the default -lock-timeout is 0s); setting -lock-timeout (for example -lock-timeout=5m) makes it wait and retry, and -lock=false disables locking (risky).

resource "random_pet" "suffix" {
  length = 2
}

resource "aws_instance" "web" {
  ami           = "ami-0c101f26f147fa7fd"
  instance_type = "t3.micro"

  tags = {
    Name = "web-${random_pet.suffix.id}"
  }
}
$ terraform plan

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:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami           = "ami-0c101f26f147fa7fd"
      + instance_type = "t3.micro"
      + id            = (known after apply)
    }

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

Values Terraform cannot know until the resource actually exists — server-assigned IDs, IP addresses — show as (known after apply).

Plan modes and options

  • -var and -var-file set input variables.
  • -refresh-only plans only the reconciliation of state with reality — it proposes updating state to match drifted objects and never proposes configuration changes.
  • -destroy previews a full teardown: it is the read-only preview of what terraform destroy (an alias for terraform apply -destroy) would remove.
  • -refresh=false skips the refresh, which is faster on large configurations — but plan then compares the configuration against the stored state only, so changes made outside Terraform (drift) are invisible to it.
  • -out=FILE saves the plan (see below).

Exit codes in automation

By default a successful plan exits with 0 whether or not changes are pending; only an error produces a non-zero code. Options such as -json (machine-readable output) and -input=false (no interactive prompts) change the output or the prompting, not the exit status. To make the status reveal pending changes, add -detailed-exitcode:

Exit codePlain terraform planterraform plan -detailed-exitcode
0Succeeded (with or without changes)Succeeded, no changes
1ErrorError
2Not usedSucceeded, changes present

That is the standard way a CI job detects drift or pending changes from the exit code alone.

Reading plan output: +, ~, -, and replacement

Every proposed action in plan output is marked with a symbol. + means create, ~ means update in-place (the resource survives, an argument changes), and - means destroy. When a changed argument cannot be updated in place — for example changing an aws_instance AMI — Terraform must replace the resource, with a note like "forces replacement" beside the offending argument. By default a replacement is shown as -/+: destroy the old object first, then create the new one. If the resource sets lifecycle { create_before_destroy = true }, the order flips and the plan shows +/-: create the replacement first, then destroy the old object. Data sources that will be read during apply are marked <=.

SymbolActionMeaning
+CreateNew resource will be provisioned
~Update in-placeExisting resource modified without recreation
-DestroyResource will be removed
-/+Replace (destroy then create)Default replacement order when a change forces replacement
+/-Replace (create then destroy)Replacement with create_before_destroy ordering
<=ReadData source will be read during apply

The summary line — Plan: 1 to add, 1 to change, 1 to destroy — counts these actions, and a replacement in either order counts as both one add and one destroy. Reviewing for unexpected -, -/+ and +/- lines before approving is the whole point of the plan stage: an innocent-looking edit that forces replacement of a database is exactly the kind of surprise plan exists to catch.

Saving a plan with -out

terraform plan -out=FILE saves the generated plan to a file, and terraform apply FILE later executes exactly those actions — the plan is not recalculated, so nothing that changed in the configuration between review and execution can slip in. Without a saved plan, terraform apply computes a fresh plan at apply time, which may differ from the one you looked at. Saved plans are the backbone of CI/CD pipelines: the plan job produces the file and posts the diff for review; the apply job consumes the same file after approval.

$ terraform plan -out=tfplan
$ terraform show tfplan          # human-readable review of the saved plan
$ terraform show -json tfplan    # machine-readable JSON for policy tools
$ terraform apply tfplan

Properties of a saved plan file:

  • Binary format. Inspect it with terraform show, not a text editor.
  • May contain sensitive values (including variable values and provider data), so store and pass it like a secret.
  • Variables are embedded. Input variable values are captured in the plan, so you do not pass -var again at apply time.
  • No approval prompt. Applying a saved plan skips the interactive yes, because the review happened when the plan was generated.
  • Stale plans are rejected. The plan is tied to the state it was created from; if the state has changed since (for example another apply ran), Terraform refuses to apply it and you must plan again.

Tip. Questions are workflow scenarios: which command comes next for a fresh clone, a new provider or module, a changed provider argument or a changed backend; how plain init, init -upgrade, the lock file and module version arguments decide which versions get installed; which backend flag (-migrate-state, -force-copy, -reconfigure, -backend-config, -backend=false) fits a situation; what validate checks compared with fmt -check and plan; what plan reads, locks and changes, its modes (-refresh-only, -destroy, -refresh=false) and exit codes with and without -detailed-exitcode; reading the +, ~, -, -/+, +/- and <= symbols and the summary counts; and the behaviour of saved plan files.

Key takeaways
  • The core workflow is Write, Plan, Apply — plan previews changes as a diff, apply executes them, and the loop repeats for every change.
  • terraform init must run first in any new or cloned directory, and again when providers, modules (source or version) or the backend change — not when a provider argument like region or a resource argument changes. It creates no infrastructure and, with the local backend, no state file.
  • terraform get installs modules only; terraform providers lock updates the lock file without installing plugins. Neither replaces init.
  • .terraform.lock.hcl pins provider versions and checksums and is committed; .terraform is not. Plain init reinstalls the locked version; init -upgrade picks the newest version within the constraints (never past them), upgrades modules too, and rewrites the lock file to commit.
  • Modules are not in the lock file. For registry modules, only an exact version ("3.1.0") installs identical module code on every run; Git sources pin with ?ref= set to a tag or commit.
  • A backend change needs -migrate-state (copy state; add -force-copy to skip prompts, which still overwrites the destination) or -reconfigure (new backend already holds the right state). -backend-config supplies partial configuration and is accepted by init only.
  • terraform validate checks syntax and internal consistency offline after init (use init -backend=false in CI); fmt -check checks only formatting; plan -refresh=false still needs providers and state.
  • terraform plan is read-only and holds the state lock; -refresh=false skips drift detection, -refresh-only reconciles state, -destroy previews a teardown. A plain plan exits 0 even with changes; -detailed-exitcode returns 2 when changes are pending.
  • Plan symbols: + create, ~ update in-place, - destroy, -/+ replace destroy-first, +/- replace create-first (create_before_destroy), <= read; a replacement counts as one add plus one destroy.
  • terraform plan -out=tfplan saves a binary, possibly sensitive plan; terraform apply tfplan executes exactly it without a prompt, with variables embedded, and rejects it if the state has changed since.

Frequently asked questions

When do I need to run terraform init again?

Run init again whenever the working directory's dependencies change: you add a provider requirement, add a module block or change a module's source or version, or add or modify the backend configuration. Terraform detects the mismatch and refuses to plan until you reinitialize. Changing a provider configuration argument such as region, or editing resources, needs no re-init. Rerunning init on an unchanged directory is harmless — it never touches infrastructure.

Does terraform validate check my cloud credentials or whether resources exist?

No. Validate checks only syntax and internal consistency — legal argument names, resolvable references, matching types. It never authenticates to a provider, reads state, or verifies that values like AMI IDs actually exist. Errors of that kind appear only at plan or apply time, when Terraform contacts the provider's API.

Does terraform plan change my state file?

No. Plan reads the state and refreshes Terraform's view of real resources in memory to compute the diff, but it writes nothing — neither infrastructure nor the state file changes. Only terraform apply (or state-manipulation commands) modifies state. If you want to persist refreshed values without changing infrastructure, that is what terraform apply -refresh-only is for.

What does -/+ mean in plan output?

It marks a replacement: the resource will be destroyed and then re-created because a changed argument cannot be updated in place. Terraform annotates the specific argument with a note that it forces replacement — for example, changing the AMI of an aws_instance. In the plan summary, a replacement is counted as one resource to add and one to destroy.

Why apply a saved plan file instead of just running terraform apply?

A plain terraform apply generates a fresh plan at that moment, which can differ from the plan you reviewed if the configuration or real infrastructure changed in between. Applying a saved plan file guarantees Terraform performs exactly the reviewed actions, skips the approval prompt, and refuses to run if the state has changed since the plan was created. That review-then-apply-the-same-artifact pattern is standard in CI/CD.

What is the difference between terraform init -migrate-state and terraform init -reconfigure?

Both are used after the backend configuration changes. terraform init -migrate-state copies the existing state from the previous backend into the new one (add -force-copy to answer the confirmation prompts automatically in a pipeline, which still overwrites any state in the destination). terraform init -reconfigure ignores the previous backend configuration and copies nothing, so it is the right choice when the new backend already holds the correct state.

Should .terraform.lock.hcl be committed to version control?

Yes. The lock file records the exact provider versions and checksums init selected, so teammates and CI install identical provider builds. Commit it alongside your configuration. The .terraform directory itself, which holds the downloaded plugin binaries and module copies, is machine-local and should be gitignored. To intentionally move to newer provider versions within your constraints, run terraform init -upgrade and commit the rewritten lock file.

Does terraform plan return a non-zero exit code when there are changes?

Not by default. A successful terraform plan exits 0 whether or not changes are pending, and exits 1 on an error; options such as -json or -input=false do not change that. Add -detailed-exitcode to get 0 for no changes, 1 for an error and 2 for changes present, which is how automation detects drift or pending changes from the exit status.

Source

This lesson covers the "Core Terraform workflow" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?