Plan, Apply & Import
The day-to-day workflow + how to bring existing infra in.
Plan, Apply & Import
The day-to-day Terraform workflow. The bits you'll run hundreds of times.
init
terraform init
Run once per project (and re-run after changing the backend or required providers). It:
- Downloads providers into
.terraform/providers/. - Initialises the backend (creates the state lock table on first run, etc.).
- Installs modules.
- Records the result in
.terraform.lock.hcl. Commit this file.
plan
terraform plan # show all proposed changes
terraform plan -out=plan.tfplan # save the plan to apply later
terraform plan -target=aws_s3_bucket.logs # plan a subset (use sparingly)
plan reads your config, refreshes state from the cloud, and prints a diff. Read it. Three things to watch for:
-/+symbols = forces replacement. Critical for stateful resources.- Implicit dependencies changing things you didn't expect.
- A larger blast radius than the diff you wrote — usually means a module upstream changed.
In CI, save the plan with -out and apply that exact plan in the next step. Avoids "what if the cloud changed between plan and apply?"
apply
terraform apply # plan + prompt + apply
terraform apply -auto-approve # skip the prompt (CI / scripts)
terraform apply plan.tfplan # apply a saved plan exactly
Apply takes the plan and executes it. Prints each resource as it changes. On failure, the partially-applied state is recorded — re-running apply continues from where it stopped.
destroy
terraform destroy
Destroys every resource in the state. Useful for tearing down dev environments. NEVER run against prod without prevent_destroy lifecycle rules in place AND a thorough plan review.
import
When a resource exists in the cloud but not in state — manually created, inherited from another team, migrated from a different tool — import adds it to state without recreating it.
Two ways:
# CLI form (older).
terraform import aws_s3_bucket.logs my-app-logs
# Block form (1.5+) — declarative, can be planned.
import {
to = aws_s3_bucket.logs
id = "my-app-logs"
}
resource "aws_s3_bucket" "logs" {
bucket = "my-app-logs"
}
After import, your resource block must match the actual cloud configuration — otherwise the next plan will try to "fix" it. Generate-and-edit cycle:
terraform plan -generate-config-out=generated.tf
Generates a .tf file matching the imported resource. Diff against your declared block, reconcile, delete the generated file.
fmt + validate
terraform fmt -recursive # auto-format every .tf
terraform validate # syntax + reference checks (no cloud calls)
Wire both into pre-commit + CI. They're fast and catch the boring class of mistakes before plan time.
Workflow in CI
The canonical CI pipeline:
on PR open / push:
terraform fmt -check -recursive
terraform init -backend=false # don't actually connect to backend
terraform validate
on PR open (against main):
terraform init
terraform plan -out=plan.tfplan
post the plan output as a PR comment
on merge to main:
terraform init
terraform apply plan.tfplan # apply the plan that was reviewed
The "plan posted as a PR comment, then apply that exact plan on merge" pattern is what keeps prod safe. Reviewers see the diff in the PR; the merge applies what was reviewed.
Atlantis + Terraform Cloud
Two popular automations of the above flow:
- Atlantis — open-source bot that posts plans on PR open + applies on merge / comment. You host it.
- Terraform Cloud / Enterprise — HashiCorp's hosted version. State + plans + applies + RBAC + cost estimation in one product.
Both encode the "plan in PR, apply after review" loop so it doesn't depend on engineers running commands locally.
Things that ruin your day
terraform apply -targetmore than once. Targeting bypasses dependency tracking. Use only as an emergency escape hatch.- A teammate running apply with a stale state. Lock the backend (DynamoDB / Cloud); make local state impossible.
- Running apply on the wrong workspace / environment. Tag every resource with the environment name; check
terraform workspace showbefore apply. - "It worked locally". CI runs the same code; if your local changes pass + CI fails, your local state has drift. Investigate.