Terraform basics: state, plan and apply
- Explain what
resource,data,variable,moduleandoutputblocks each do, and choosedatafor something you read but don't own - Explain why state lives in a locked remote backend, one per environment, and is changed through Terraform rather than by editing the file
- Read a plan: tell an in-place update from a replacement, predict the add/change/destroy line, and recognise drift and what
-refresh-onlydoes about it - Rename a resource with a
movedblock and adopt an existing object with animportblock instead of destroying and recreating it - Write a check over
terraform show -jsonoutput that summarises a plan and flags every change that deletes an object
Read
You write the end state; Terraform works out the steps
FlagA Terraform configuration never says "create a server, then write a file". It says what should exist. Terraform compares that with what it already manages and proposes the difference. That is what declarative means here: you edit the destination, never the route.
resource "random_pet" "server" {}
resource "local_file" "motd" {
filename = "${path.module}/motd.txt"
content = "hello from ${random_pet.server.id}\n"
}
(random and local need no cloud account, so every example in this unit runs on a laptop.
An aws_db_instance follows exactly the same rules.)
The vocabulary:
- A provider is the plugin that talks to one API —
hashicorp/aws,hashicorp/random.terraform initdownloads the ones you declare and pins their exact versions in.terraform.lock.hcl, which you commit. - A resource block declares one object that Terraform creates and owns. Type plus name,
random_pet.server, is its address. - A reference such as
random_pet.server.idis also a dependency. Terraform creates the pet before the file because the file's content needs the pet's name.
Then the loop, with real output:
$ terraform plan
# local_file.motd will be created
+ resource "local_file" "motd" {
+ content = (known after apply)
+ filename = "./motd.txt"
...
# random_pet.server will be created
+ resource "random_pet" "server" {
+ id = (known after apply)
...
Plan: 2 to add, 0 to change, 0 to destroy.
$ terraform apply
random_pet.server: Creation complete after 0s [id=smashing-scorpion]
local_file.motd: Creation complete after 0s [id=f5820057…]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
planchanges nothing. It reads the real objects, compares them with the configuration, and proposes actions.(known after apply)marks values that won't exist until the object is created — the pet's name, so also the file's content.applymakes a plan real. Run bare, it computes a fresh plan, shows it, and waits foryes.- Run
planagain right after, and it reports No changes: the real world now matches the file. Editing the file is how you change infrastructure; the next plan shows the difference.
Reading a plan — update in place, replace, and what the summary counts
FlagThe configuration has one variable, env, used in three places:
variable "env" {
type = string
default = "staging"
}
resource "random_pet" "server" {
keepers = { env = var.env } # a new env means a new pet
}
resource "terraform_data" "config" {
input = { env = var.env, replicas = 2 }
}
resource "local_file" "motd" {
filename = "${path.module}/motd.txt"
content = "hello from ${random_pet.server.id}\n"
}
It was applied with staging. Here is the real plan for -var env=production, with the
checksum lines of the file trimmed:
~ update in-place
-/+ destroy and then create replacement
# local_file.motd must be replaced
-/+ resource "local_file" "motd" {
~ content = <<-EOT
hello from evident-satyr
EOT -> (known after apply) # forces replacement
~ id = "dd375f8f…" -> (known after apply)
# random_pet.server must be replaced
-/+ resource "random_pet" "server" {
~ id = "evident-satyr" -> (known after apply)
~ keepers = { # forces replacement
~ "env" = "staging" -> "production"
}
# terraform_data.config will be updated in-place
~ resource "terraform_data" "config" {
id = "4971ef44-…"
~ input = {
~ env = "staging" -> "production"
Plan: 2 to add, 1 to change, 2 to destroy.
Line by line:
~update in place. The same object (note the unchangedid) gets a new value. Cheap and usually safe.-/+must be replaced. The provider can't change that attribute on a live object, so the object is destroyed and a new one created — new ID, new identity.# forces replacementnames the attribute responsible. For a database, a disk or a DNS record, that is the line to read before anything else.- Replacement spreads. Nothing about the file changed directly, but its content embeds the pet's name, which will be new — so the file is replaced too.
- The summary counts objects, not resources. Each replacement destroys one object and
creates one, so two replacements plus one update read
2 to add, 1 to change, 2 to destroy. "1 to change" is only the in-place update. Skim the summary for "change" and you'll miss that a replace is a destroy.
Two more symbols you'll meet:
+/-create replacement and then destroy — the same replacement, reversed, when the resource setslifecycle { create_before_destroy = true }. The default is destroy-then-create.<=read — a data source being read during apply because something it depends on is still changing. Reads never appear in the add/change/destroy line.
Plan once, apply that plan
terraform apply with no argument re-plans, and the world may have moved since you reviewed.
In CI, save the plan and apply exactly it:
terraform plan -out=tfplan # the file you review (or a bot summarises)
terraform apply tfplan # applies those actions — no new plan, no prompt
Terraform's own hint after -out reads "To perform exactly these actions, run …". A plan
file contains the configuration and variable values, sensitive ones included, so treat it
like state. For scripts, terraform plan -detailed-exitcode exits 0 for no changes, 2 for
changes and 1 for an error.
Drift — when reality changes behind Terraform's back
FlagDrift is a difference between a real object and what Terraform last recorded: someone edited a security group in the console, an autoscaler changed a count, a person "just fixed it" on the server. Every normal plan starts by refreshing — asking the provider what each object looks like now — and then plans from that.
Here someone overwrote motd.txt by hand. The next normal plan:
# local_file.motd will be created
+ resource "local_file" "motd" {
...
Plan: 1 to add, 0 to change, 0 to destroy.
A plan that only creates looks harmless, but nothing in the configuration changed — this is Terraform undoing the hand edit. To see what it detected, ask for a refresh-only plan:
$ terraform plan -refresh-only
Note: Objects have changed outside of Terraform
# local_file.motd has been deleted
- resource "local_file" "motd" { ... }
This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.
How drift is reported depends on the provider. local compares the file's checksum and
reports a file with different content as gone, so the fix is a create. A cloud provider
usually reports the changed attribute, and the normal plan shows a ~ update that puts the
configured value back. (A normal plan prints its own "Objects have changed outside of
Terraform" note only for changes that may have affected the plan; -refresh-only lists
everything it found.) Either way: a normal plan makes reality match the configuration
again.
So you choose, per change:
| You want… | Do this |
|---|---|
| the configured value back | a normal plan and apply — that's the revert |
| to keep the outside change | edit the configuration to match; the next plan is empty |
| state to record reality, touching nothing | terraform apply -refresh-only |
| Terraform to leave one attribute alone for good | lifecycle { ignore_changes = [tags] } |
The trap is the third row. -refresh-only updates state, never the configuration. After
accepting the hand edit that way, the very next normal plan still read:
# local_file.motd will be created
Plan: 1 to add, 0 to change, 0 to destroy.
The configuration still declares the file, so Terraform still wants it. Keeping an outside
change means editing the code; -refresh-only is for looking, and for refreshing outputs that
other configurations read from your state.
ignore_changes is for attributes something else legitimately owns — tags a compliance tool
manages, a count an autoscaler moves — not for silencing drift you don't understand.
Quiz
Your service needs the ID of a VPC that the network team manages in their own Terraform. Which block gives you its attributes without making the VPC yours?
FlagThere's no remote backend, and the only terraform.tfstate gets deleted. The real objects are untouched. What does the next terraform plan propose?
Two CI jobs run terraform apply against the same S3 backend with use_lockfile = true, a few seconds apart, with default flags. What happens to the second job?
A plan shows -/+ resource "aws_db_instance" "main" and one attribute marked # forces replacement. What will apply do?
A plan replaces one database and updates the tags on one security group. Which summary line does Terraform print?
FlagSomeone hand-edited an object and you want to keep their change. You run terraform apply -refresh-only, and change nothing else. What does the next normal plan propose?
You rename aws_db_instance.main to aws_db_instance.primary in the code and change nothing else. What does the next plan propose, and what's the fix?
Staging and production should share Terraform code, and the staging CI role must not be able to touch production. Which layout do Terraform's docs point to?
FlagIn terraform show -json output, a change has "actions": ["create", "delete"]. What is it?
Fill in the blank
Read a VPC you don't own, and expose its ID as an output.
Flag"aws_vpc" "shared" { tags = { Name = "shared" } } output "vpc_id" { value = .aws_vpc.shared.id }
Keep production state in S3 with S3-native locking turned on (generally available since Terraform 1.11).
Flagterraform { "s3" { bucket = "acme-terraform-state" key = "shop/production/terraform.tfstate" region = "eu-west-1" use_lockfile = } }
Save a plan, turn it into JSON for a CI check, then apply exactly the plan that was checked.
Flagterraform plan -=tfplan terraform show - tfplan > plan.json terraform apply tfplan
Rename the database's address without destroying it.
Flag{ from = aws_db_instance.main = aws_db_instance.primary }
Adopt a bucket that was created by hand (Terraform 1.5+). A matching resource "aws_s3_bucket" "assets" block already exists.
import { = aws_s3_bucket.assets = "acme-assets-prod" }
Predict the output
variable "env" {
type = string # no default
}
resource "random_pet" "server" {
keepers = { env = var.env }
}
# In CI, with no -var, no .tfvars file and no TF_VAR_env set:
# terraform plan -input=falsevariable "env" { default = "staging" }
resource "random_pet" "server" {
keepers = { env = var.env } # changing a keeper forces a new pet
}
resource "terraform_data" "config" {
input = { env = var.env, replicas = 2 } # updates in place
}
resource "local_file" "motd" {
filename = "${path.module}/motd.txt"
content = "hello from ${random_pet.server.id}\n"
}
# Applied with the default. Now: terraform plan -var env=production# Applied earlier:
resource "random_pet" "worker" {}
# Now the file says this instead, and nothing else changed:
resource "random_pet" "api" {}
# terraform plan# The real object was created earlier with min = 8000, max = 8999 (result 8443).
import {
to = random_integer.port
id = "8443,8000,8999"
}
resource "random_integer" "port" {
min = 8000
max = 9999 # differs from the real object; changing max forces replacement
}
# terraform planChallenge
Summarise a Terraform plan and gate what auto-applies
FlagA CI job runs terraform plan -out=tfplan, then terraform show -json tfplan, and hands the
JSON to plan-gate.ts. The gate does two things: it prints the same summary Terraform would
(Plan: 1 to import, 2 to add, 1 to change, 2 to destroy.) in the pull request, and it
decides whether the plan may be applied without a human looking at it.
sample-plans.ts holds seven real plans from Terraform 1.16.6, each with the summary line
Terraform printed for it. The preview shows your summary next to Terraform's.
Each entry in resource_changes carries change.actions, and the documented values are:
["no-op"] ["create"] ["read"] ["update"] ["delete"]
["delete", "create"] replace (destroy first — the default)
["create", "delete"] replace with create_before_destroy
The gate works on the easy plans and is wrong on the dangerous ones:
- Replacements are misread. Reading only
actions[0]turns["delete", "create"]into a destroy and["create", "delete"]into a create. - No-ops and reads look like updates. Most entries in a real plan are
["no-op"], and a data source shows up as["read"]; neither changes anything. - A replace is counted as a change. Terraform counts objects: a replacement destroys one and creates one. "1 to change" means in-place updates only — a reviewer who skims for "destroy" must see the replaced database there.
- Imports are never counted. An adopted object carries
change.importing, whatever itsactionsare — usually["no-op"], sometimes a replacement. - The approval gate only catches a plain destroy. A replacement also deletes an object,
and with
create_before_destroyits first action iscreate.
Your task
Edit plan-gate.ts so the tests pass:
describeChangereturnsreplacefor both["delete", "create"]and["create", "delete"]- No-op changes and data-source reads add nothing to the summary
- A replacement counts as one add and one destroy, never as a change
- Every change that carries
importingcounts once underimport, whatever its actions needsApprovalis true whenever any change's actions includedelete, and false otherwise- Every sample plan's summary matches the
Plan:line Terraform printed for it
Further reading
- Terraform docs: State
- Terraform docs: State locking
- Terraform docs: Backend block configuration
- Terraform docs: S3 backend
- Terraform docs: terraform plan command
- Terraform docs: JSON output format
- Terraform docs: Refactor modules (moved blocks)
- Terraform docs: import block
- Terraform docs: lifecycle meta-argument
- Terraform docs: Manage sensitive data
- Terraform docs: CLI workspaces
- OpenTofu docs