Infrastructure
Infrastructure

Terraform basics: state, plan and apply

Full · ~50 min
After this unit you can
  • Explain what resource, data, variable, module and output blocks each do, and choose data for 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-only does about it
  • Rename a resource with a moved block and adopt an existing object with an import block instead of destroying and recreating it
  • Write a check over terraform show -json output that summarises a plan and flags every change that deletes an object

Read

Simple
1 / 2

You write the end state; Terraform works out the steps

Flag

A 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 init downloads 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.id is 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.
  • plan changes 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.
  • apply makes a plan real. Run bare, it computes a fresh plan, shows it, and waits for yes.
  • Run plan again 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.

Medium
1 / 2

Reading a plan — update in place, replace, and what the summary counts

Flag

The 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 unchanged id) 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 replacement names 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 sets lifecycle { 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.

Complex
1 / 3

Drift — when reality changes behind Terraform's back

Flag

Drift 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 backa normal plan and apply — that's the revert
to keep the outside changeedit the configuration to match; the next plan is empty
state to record reality, touching nothingterraform apply -refresh-only
Terraform to leave one attribute alone for goodlifecycle { 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?

Flag

There's no remote backend, and the only terraform.tfstate gets deleted. The real objects are untouched. What does the next terraform plan propose?

Flag

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?

Flag

A plan shows -/+ resource "aws_db_instance" "main" and one attribute marked # forces replacement. What will apply do?

Flag

A plan replaces one database and updates the tags on one security group. Which summary line does Terraform print?

Flag

Someone 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?

Flag

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?

Flag

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?

Flag

In terraform show -json output, a change has "actions": ["create", "delete"]. What is it?

Flag

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).

Flag
terraform {
   "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.

Flag
terraform 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.

Flag
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=false
Flag
variable "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
Flag
# Applied earlier:
resource "random_pet" "worker" {}

# Now the file says this instead, and nothing else changed:
resource "random_pet" "api" {}

# terraform plan
Flag
# 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 plan
Flag

Challenge

Summarise a Terraform plan and gate what auto-applies

Flag

A 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:

  1. Replacements are misread. Reading only actions[0] turns ["delete", "create"] into a destroy and ["create", "delete"] into a create.
  2. 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.
  3. 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.
  4. Imports are never counted. An adopted object carries change.importing, whatever its actions are — usually ["no-op"], sometimes a replacement.
  5. The approval gate only catches a plain destroy. A replacement also deletes an object, and with create_before_destroy its first action is create.

Your task

Edit plan-gate.ts so the tests pass:

  • describeChange returns replace for 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 importing counts once under import, whatever its actions
  • needsApproval is true whenever any change's actions include delete, and false otherwise
  • Every sample plan's summary matches the Plan: line Terraform printed for it

Loading the in-browser editor…

Further reading