State is the part of Terraform people skip, and then get burned by. It is also the thing that makes Terraform work at all — every other tool that manages infrastructure declaratively has an equivalent, and Terraform's happens to be a JSON file you can look at.
Why it has to exist
You write "an S3 bucket named X exists". Terraform needs to answer three questions before it can plan:
1. does it exist? ask AWS
2. is it configured correctly? ask AWS
3. did *I* create it? <-- AWS cannot answer thisQuestion 3 is why state exists. Without it, deleting a resource from your .tf files
would mean nothing — Terraform would have no record that it was ever responsible for the thing, so
it could not know to destroy it. And it could not tell "someone renamed this bucket" from "this is a
different bucket that happens to be here".
State is Terraform's memory: a map from your resource addresses to real object ids, plus a cached copy of every attribute as of the last read.
aws_s3_bucket.first -> tf-first-bucket-8f3a1c
module.network.aws_vpc.this -> vpc-0aefaffec4178776eWhat is actually in the file
terraform show # human-readable
terraform state list # just the addresses
terraform state show aws_s3_bucket.first{
"version": 4,
"terraform_version": "1.15.9",
"serial": 93,
"lineage": "b0d1c3a2-...",
"resources": [
{
"mode": "managed",
"type": "aws_s3_bucket",
"name": "first",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"attributes": {
"id": "tf-first-bucket-8f3a1c",
"arn": "arn:aws:s3:::tf-first-bucket-8f3a1c",
"tags": { "Environment": "learning" }
}
}
]
}
]
}serial increments on every write and is what makes locking meaningful.
lineage identifies this state's ancestry — Terraform refuses to overwrite a state with
a different lineage, which is the guard against pointing a configuration at the wrong backend.
⚠️ It contains secrets, in cleartext
This is the single most important fact about state.
Every attribute is cached, including the ones marked sensitive. An RDS password set through
Terraform is in that file as text. So is a generated random_password. So is the body of
any secret you created.
# prove it to yourself
grep -o '"password":"[^"]*"' terraform.tfstateMarking a variable sensitive = true redacts it from console output. It
changes nothing about what is written to disk. There is no encryption of the state file by
Terraform.
Which gives three rules: state never goes in git, state lives in an encrypted bucket with tight access (lesson 8), and where a platform can generate and hold a secret itself, let it — lesson 12 uses RDS-managed passwords so the value never reaches Terraform at all.
Dependencies come from references
Terraform builds a graph and works out the order itself. You almost never state it:
resource "aws_vpc" "this" {
cidr_block = "10.20.0.0/16"
}
resource "aws_subnet" "public" {
# THIS reference is the dependency. Terraform sees aws_vpc.this in the
# expression, so it creates the VPC first and waits for its id.
vpc_id = aws_vpc.this.id
cidr_block = "10.20.0.0/24"
}Independent resources are created in parallel — ten by default. That is why an apply is much faster than the same thing done by hand, and why the order in your file means nothing.
depends_on is for the dependencies that are real but invisible — where nothing in the
arguments references the other resource:
resource "aws_ecs_service" "this" {
name = "pizza-tf"
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
# ECS checks it can assume the execution role when the service is created,
# and IAM is eventually consistent. Nothing in the arguments above mentions
# the policy attachment, so Terraform would happily create the service first
# and fail on a role that "does not exist" — a race that passes on a retry
# and looks like flakiness.
depends_on = [
aws_iam_role_policy_attachment.execution_managed,
]
}Use it sparingly. Over-using depends_on serialises the graph and slows every apply;
each one should have a comment saying what the invisible dependency is.
lifecycle
resource "aws_db_instance" "this" {
identifier = "pizza-tf-mysql"
engine = "mysql"
instance_class = "db.t4g.micro"
lifecycle {
# Refuse to destroy this, ever. `terraform destroy` ERRORS rather than
# deleting it. The guard is real: removing this block is a deliberate,
# reviewable commit, which is exactly the friction you want in front of
# a production database.
prevent_destroy = true
# Create the replacement BEFORE destroying the old one. Only useful where
# a name collision will not stop it — two things cannot both hold one
# unique identifier.
create_before_destroy = false
# Do not plan a change when these drift. `desired_count` is the classic:
# if an autoscaler owns it at runtime, Terraform will otherwise fight the
# autoscaler on every apply.
ignore_changes = [tags["LastModified"]]
}
}ignore_changes is a sharp tool. It makes Terraform stop being the source of truth for
that attribute, which is right when something else legitimately owns it and wrong the rest of the
time — a drifted value simply stops being reported.
When state and reality disagree
Someone changes something in the console. A resource is deleted out from under you. This happens, and these are the commands for it.
Something changed in the console. Nothing to do — plan refreshes
from AWS every run, so it will show you the drift and offer to put it back. That is the correct
outcome: your files are the source of truth.
You renamed a resource in your files. Terraform sees the old address gone and a
new one appear, and plans destroy-then-create. Use a moved block instead:
moved {
from = aws_s3_bucket.old_name
to = aws_s3_bucket.new_name
}This is checked into the code, so it works for everyone including CI. It replaced
terraform state mv, which did the same thing as a manual, unrepeatable command.
Something exists that Terraform did not create. Adopt it with an
import block:
import {
to = aws_s3_bucket.existing
id = "a-bucket-that-already-exists"
}
resource "aws_s3_bucket" "existing" {
bucket = "a-bucket-that-already-exists"
}terraform plan then shows what it would adopt and what would change, which is the
part the old terraform import command could not do. plan -generate-config-out=x.tf
will even write the resource block for you.
You want a resource rebuilt.
terraform apply -replace=aws_ecs_service.thisThis is the replacement for terraform taint, and it is better because it goes through
plan — you see the destroy before you agree to it.
You need something out of state without destroying it.
terraform state rm aws_s3_bucket.firstTerraform forgets it; AWS still has it. Useful when handing a resource to another configuration. ⚠️ There is no undo, and the resource is now unmanaged and easy to forget about — this is the one command here that is genuinely dangerous.
What not to do
Do not hand-edit the state file. Every problem above has a command, and the
commands maintain serial and lineage correctly. If you truly must, use
terraform state pull and terraform state push, and take a backup first.
Do not run -refresh=false to make plans faster unless you know
exactly why. It plans against a cached picture of the world, and its whole value is that it does
not.
Next
Lesson 6 makes one configuration serve several environments — typed inputs, validation that fails at plan time, and the precedence order for when a variable is set four different ways.