terraform · level 9

Plan, Apply & Import

The day-to-day workflow + how to bring existing infra in.

100 XP

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 -target more 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 show before apply.
  • "It worked locally". CI runs the same code; if your local changes pass + CI fails, your local state has drift. Investigate.