Core Workflow
.mdVerified against Terraform v1.9.8, flags verified via `terraform <cmd> -help` run locally, 2026-08-29 · official docs
What it is and where it fits 🎯#
Terraform is the standard tool for declarative, provider-agnostic infrastructure as code — you describe the desired end state in HCL, and Terraform computes and executes the diff against a state file that records what it last knew to be true. That state file is the single most important concept to internalize before anything else on this page: Terraform's plan/apply cycle is fundamentally a three-way comparison between your configuration (what you wrote), your state (what Terraform last recorded), and real infrastructure (what actually exists) — most confusing Terraform behavior traces back to one of those three drifting from the other two. The everyday loop covered on this page: initialize, validate, plan, apply, destroy — plus formatting and variable input. See the companion page for state surgery, workspaces, and the console.
The plan/apply cycle#
Initializing a working directory#
terraform init
terraform init -upgrade # also upgrade provider/module versions to the latest allowed
terraform init -backend=false # skip remote backend setup (e.g. for a quick local validate)init is always safe to re-run — it never deletes configuration or state. Run it any time you add a new provider, module, or change the backend block.
Validating configuration#
terraform validate
terraform validate -json # machine-readable output, useful in CI
terraform fmt # rewrite files to canonical formatting
terraform fmt -check # exit non-zero if formatting would change (CI check, no rewrite)
terraform fmt -recursive # format all subdirectories toovalidate only checks syntax and internal consistency — it does not contact providers or check whether your credentials/values would actually work against real infrastructure. That's what plan is for.
Planning changes#
terraform plan
terraform plan -out=tfplan # save the plan to a file for a later, exact apply
terraform plan -var="instance_count=3"
terraform plan -var-file="prod.tfvars"
terraform plan -target=aws_instance.web # limit planning to one resource/module (use sparingly)
terraform plan -destroy # preview what a destroy would do, without doing it-target is a scalpel for a specific fix or debugging session, not a routine workflow — repeatedly targeting individual resources instead of planning the whole configuration can let real drift between your state and the full config go unnoticed.
Applying changes#
terraform apply
terraform apply tfplan # apply an exact, previously-saved plan — no new plan, no prompt
terraform apply -auto-approve # skip the interactive yes/no prompt (CI pipelines)
terraform apply -var="instance_count=3"Applying a saved plan file (terraform apply tfplan) is the safer pattern for CI/CD — it guarantees the infrastructure change applied is exactly what was reviewed in the plan step, with no window for the underlying config or state to drift between plan and apply.
Destroying infrastructure#
terraform destroy
terraform destroy -target=aws_instance.web # destroy a single resource
terraform destroy -auto-approvedestroy is a convenience alias for apply -destroy — same safety considerations apply: no undo, and CI usage should require explicit human approval unless the environment is genuinely disposable (e.g. ephemeral PR preview environments).
The plan-file workflow (plan -out / apply <plan-file>)#
terraform plan -out=tfplan # write the plan to a binary file instead of just printing it
terraform show tfplan # re-read a saved plan file in human-readable form
terraform show -json tfplan # machine-readable plan, for CI gating/policy checks
terraform apply tfplan # apply exactly what's in the saved plan — no re-plan, no promptThis is the two-step pattern CI/CD pipelines should use instead of a bare terraform apply: a "plan" job produces and uploads tfplan as a build artifact, a human or a policy gate reviews it (terraform show tfplan renders it back to readable form), and a separate "apply" job downloads that exact artifact and runs terraform apply tfplan. Because the applied plan is a file, not a re-evaluation of the current config, there's no window for drift between what was reviewed and what gets applied.
Forcing resource replacement#
terraform plan -replace=aws_instance.web # preview replacing one resource instance, without applying
terraform apply -replace=aws_instance.web # replace it — destroy + recreate in the same apply-replace is a plan-customization flag (also accepted by apply directly, per terraform plan -help) — the modern equivalent of the older terraform taint/terraform untaint commands. Prefer -replace in new scripts: it's explicit about which apply/plan the replacement lands in, whereas taint mutates state ahead of time and is easy to forget you left set.
Modules#
terraform init # also downloads any modules referenced by `source = "..."`
terraform init -upgrade # re-resolve modules (and providers) to the latest allowed versions
terraform get # download/update modules only, without a full init
terraform get -update # re-download modules even if already present locallyModule source addresses come in a few common forms: a local relative path (./modules/vpc), a Terraform Registry reference (terraform-aws-modules/vpc/aws), or a Git URL (git::https://example.com/vpc.git?ref=v1.2.0). Pin registry and Git module sources to an explicit version/tag in production configs — an unpinned Git ref (or none at all, which defaults to the default branch) means the module's code can change out from under you on the next init -upgrade.
Provider version constraints#
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # any 5.x, but not 6.0
}
}
}terraform providers # print the resolved provider requirements tree for this config
terraform providers lock # write/update .terraform.lock.hcl for the constrained providersThe version constraint in required_providers only bounds which versions Terraform is allowed to select — the actual version in use for a given init is recorded in .terraform.lock.hcl, which should be committed to version control so every teammate and CI run resolves the identical provider version until someone deliberately runs init -upgrade.
Visualizing the dependency graph#
terraform graph # DOT-format dependency graph of the current config
terraform graph -type=plan # graph the more detailed plan-time evaluation, not just the config summary
terraform graph | dot -Tsvg > graph.svg # render to an image (requires Graphviz's `dot` installed separately)graph outputs raw DOT — Terraform does not render an image itself. Useful for untangling "why does changing this one variable seem to trigger changes across half my resources" in a large configuration.
Importing existing infrastructure#
terraform import aws_instance.web i-0123456789abcdef0import {
to = aws_instance.web
id = "i-0123456789abcdef0"
}terraform plan -generate-config-out=generated.tf # (experimental in v1.9.8) write a starting .tf config for the import
terraform apply # actually perform the import (plus any other planned changes)Two different import mechanisms exist in this version: the older terraform import CLI command (imports into state only — you still hand-write the matching resource block, or plan will show a large diff), and the newer import {} configuration block combined with -generate-config-out, which can generate a starting .tf file for you from the real object's attributes. The block-based approach is the current recommended pattern for anything beyond a one-off import, since it's declarative, reviewable in a plan, and repeatable — but always review the generated config carefully before committing it, it's a starting point, not guaranteed-correct code.
Real-world scenario: a safe CI/CD pipeline using the plan-file pattern#
# .github/workflows/terraform.yml
name: Terraform
on: [pull_request, push]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -out=tfplan
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: tfplan }
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # GitHub Environments can require manual approval here
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- uses: actions/download-artifact@v4
with: { name: tfplan }
- run: terraform apply tfplanImportant
The apply job downloads and applies the exact plan artifact the plan job produced — it never
re-plans. This closes the window between "what a human/policy gate reviewed" and "what actually got
applied" that a bare terraform apply in CI would leave open (someone could merge an unrelated change to
main between plan and apply, or a provider's state of the world could shift, silently changing what apply
would do versus what was reviewed).
Common pitfalls#
- Running a bare
terraform applyin CI without a saved plan file — see the IMPORTANT callout above. - Routine use of
-target— see the note under Planning changes; it's a debugging scalpel, not a habit. - Committing
.terraform.lock.hclinconsistently across a team — every teammate and CI run should resolve to the identical provider version; skipping the lock file (or.gitignore-ing it) reintroduces exactly the "works on my machine" drift Terraform's locking exists to prevent. - Trusting an unpinned Git module
ref— the module's code can silently change out from under a config on the nextinit -upgradewith no version bump to signal it.
Exit codes#
0 success, no changes (or -detailed-exitcode not set) · with plan -detailed-exitcode: 0 no changes,
2 changes present, 1 error · apply/destroy: non-zero on any failure.