Skip to main content
RunBook Academy

TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform

CRUD and the Resource Lifecycle

Foundation⏱ ~12 minbash

What you'll learn

  • Explain the four CRUD operations Terraform performs against a provider API
  • Read the plan symbols +, ~, -/+, +/- and - and map them to lifecycle actions
  • Distinguish in-place update from destroy-then-create replacement
  • Recognise the role of refresh in the read phase of an apply

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

When you run terraform apply against a configuration, the tool performs the four CRUD operations against your provider: it creates resources that do not exist, reads the state of resources that do, updates the ones whose configuration has drifted, and destroys the ones that have been removed from the configuration. The vocabulary is borrowed from databases; the mechanics are not. This lesson is the operational ground truth for what terraform apply actually does at the API layer.

The four operations

CRUD stands for Create, Read, Update, Delete. In Terraform:

  • Create — a resource block exists in the configuration but no state entry exists. Terraform calls the provider’s Create API.
  • Read — Terraform calls the provider’s Read (or Refresh) API to compare the live resource against the state. This happens during the plan phase and during the first half of the apply phase.
  • Update — the configuration and state disagree on a non-ForceNew attribute. Terraform calls the provider’s Update API to mutate the resource in place.
  • Delete — the resource block has been removed from the configuration, or count / for_each shrank, or the resource has been marked for replacement. Terraform calls the provider’s Delete API.
Configuration          State                  Live infrastructure
       |                  |                          |
       |  Read (refresh)  |   Read (API call)        |
       |<-----------------+------------------------->|
       |                  |                          |
       |  Compare         |                          |
       +----------------->|                          |
       |                  |                          |
       |  Action: Create / Update / Delete / Replace |
       +----------------->|------------------------->|

The plan is the comparison step. The apply is the action step.

Plan symbols and what they mean

terraform plan annotates every change with a symbol. The symbols are terse, but each one is precise:

SymbolMeaningAPI action
+Resource will be createdCreate
~Resource will be updated in placeUpdate
-Resource will be destroyedDelete
-/+Destroy then create (forced replacement)Delete + Create
+/-Create then destroy (create_before_destroy)Create + Delete
<=Data source will be readRead

A real example. Edit instance_type from t3.small to t3.medium on an EC2 instance:

terraform plan
  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
        ami           = "ami-0c55b159cbfafe1f0"
      ~ instance_type = "t3.small" -> "t3.medium"
        id            = "i-0abcd1234ef567890"
      + tags          = {
          + "Name" = "web-1"
        }
        # (14 unchanged attributes hidden)
    }

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

Now change the AMI:

terraform plan
  # aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
        ~ ami           = "ami-0c55b159cbfafe1f0" -> "ami-0a1b2c3d4e5f67890" # forces replacement
        id            = "i-0abcd1234ef567890"
      ~ instance_type = "t3.small" -> "t3.medium"
        # ...
    }

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

The must be replaced line and the # forces replacement comment are provider-supplied. They are the canonical signal that the change is not safe in-place. The lesson on replacement covers the reasons in detail.

How to inspect the plan programmatically

For production change reviews, the textual plan is harder to parse than the JSON form. Use terraform show -json:

terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | {address: .address, actions: .change.actions}'
{
  "address": "aws_instance.web",
  "actions": ["delete", "create"]
}
{
  "address": "aws_instance.api",
  "actions": ["update"]
}
{
  "address": "data.aws_ami.ubuntu",
  "actions": ["read"]
}

The actions array is the lifecycle in execution order. A two-element array is a replace; a single-element array is a single CRUD action; ["no-op"] means the resource is unchanged.

Refresh: the read phase

Before the plan is computed, Terraform runs a refresh. The refresh calls Read on every resource and updates the state to match the live infrastructure. Refresh is implicit in plan and apply. It can be made explicit with terraform refresh (which is now just an alias for terraform apply -refresh-only).

terraform plan -refresh-only -out=tfplan-refresh
terraform apply tfplan-refresh

The -refresh-only flag updates state without proposing any configuration changes. It is the right tool when you suspect drift but you do not want to apply a configuration change.

Production failure modes

  1. Misreading -/+ as ~. The operator edits an attribute they believe is mutable. The plan shows ~ (in-place). They do not notice that the provider marks it ForceNew in a newer version. The next plan shows -/+. They approve without thinking, and the resource is destroyed. Recovery: restore from the state backup taken before the apply, then re-import if the resource still exists.

  2. Partial apply. terraform apply calls Create, succeeds for resource A, then crashes before resource B is created. State diverges from the configuration: A exists in the world and in state; B exists in neither. Recovery: run terraform plan; the missing B will be re-proposed. Inspect state with terraform state list.

  3. Concurrent applies. Two operators run apply at the same time. Both pass the plan stage, both call the provider API. The state lock prevents this with a DynamoDB lock (or local lock); the second apply fails with Error acquiring the state lock. Recovery: wait for the first apply to complete. Never disable the state lock.

  4. terraform refresh mutating state silently. terraform refresh updates state to match the real world without proposing a plan. If drift is the result of a manual fix and you run refresh, the fix becomes invisible to subsequent plans. Recovery: use terraform plan -refresh-only first to see what would change, then decide.

  5. Approving apply without reading. terraform apply accepts -auto-approve. CI pipelines use it. A misconfigured pipeline can apply a change that destroys production. Recovery: restore from state backup; restore the resource from the provider console if the state is gone.

Security and performance

Security. Every CRUD call hits the provider API. The IAM role used by Terraform must allow Create, Read, Update, Delete on every resource type you manage. Most production incidents involving “permission denied” during apply are IAM scope mismatches that were not caught in development.

Performance. A large apply issues many API calls. Most providers rate-limit per account per region. The plan engine parallelises independent resources, but the apply phase is bounded by the provider rate limits. For large estates, run terraform apply -parallelism=10 or lower to avoid tripping the limits.

What to do in production

  • Always run terraform plan -out=tfplan and read the output (text or JSON) before approving.
  • Use a CI pipeline that runs plan on every PR and apply only on merge to the main branch.
  • Configure a remote backend with state locking (S3 + DynamoDB, or Terraform Cloud) before the first team apply.
  • Never run terraform refresh against production state without a preceding -refresh-only plan.
  • Take a state backup before every apply to production. The terraform state pull > backup.tfstate pattern works.

Verification

# 1. Show the JSON plan and confirm the lifecycle actions match expectations
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[].change.actions'

# 2. Confirm the state lock is in place (remote backend assumed)
terraform force-unlock -help 2>&1 | head -1

# 3. Verify state matches reality (no drift)
terraform plan -refresh-only

# 4. Inspect the state for a single resource
terraform state show aws_instance.web

# 5. Confirm no partial apply is in flight
terraform state list

A clean verification looks like:

$ terraform plan -refresh-only
No changes. Your infrastructure matches the configuration.

Any other output means drift exists and the read phase found it.

Knowledge check · 7 questions

  1. Q1. Which plan symbol indicates that a resource will be destroyed and a new one created in its place?

  2. Q2. What does Terraform do during the refresh phase of a plan?

  3. Q3. The textual plan and the JSON plan describe the same lifecycle actions.

  4. Q4. Which of the following are valid plan actions in Terraform 1.9.x? (Select all that apply.)

  5. Q5. Why is `terraform refresh` discouraged as a standalone command in production?

  6. Q6. An operator runs `terraform apply` in production. The apply creates resource A successfully, then the network blip causes the apply to fail before resource B is created. What is the state?

  7. Q7. Which command updates state to match reality without proposing configuration changes?

Passing score: 75%. Answers are checked in this browser.