CoursesTerraformState: Terraform’s memory

State: Terraform’s memory

How Terraform tracks what it manages.

Beginner14 min · lesson 3 of 15

A coat check works because of a numbered stub. You hand over your jacket, the attendant hangs it on hook 47, and you walk away holding a ticket that says 47. The ticket is not your coat. It is the link between the thing you described at the counter and the thing hanging in the back room. Burn the ticket book and every coat is still there, perfectly fine, and completely unclaimable.

Terraform state is the ticket book. When Terraform builds something it writes a record into a state file called terraform.tfstate. That file is JSON (JavaScript Object Notation, a plain-text format for structured data), and it maps each resource address in your code, such as aws_instance.web, to the real object's identifier at the provider, such as i-0abc123def4567890. That record is how the next run knows your aws_instance.web means that exact EC2 (Elastic Compute Cloud, Amazon's virtual machine service) instance and not a brand new one, so it can decide to change it, leave it alone, or destroy it because you deleted the block. Lose the state and Terraform loses its memory. The instance keeps running, your code still asks for an instance, and Terraform, holding no ticket, cheerfully builds a second one.

Every plan is a three-way comparison, and holding all three in your head is most of the skill. Your configuration says what should exist. State says what Terraform built and what it looked like last time. The provider API (application programming interface, the cloud's own remote-control interface, which Terraform queries fresh in the refresh step at the start of every plan) says what is out there right now. The plan you read is the gap between the first and the third. State is the thread that lets Terraform line up which real object belongs to which block of code.

What happens during one terraform plan
1read state
code address to recorded ID
2refresh
ask the provider about each ID
3compare
your config vs what came back
4plan
the actions that close the gap
A plan never writes state in modern Terraform. Only apply, or apply -refresh-only, records what the refresh learned.

What is actually in the file

Open it once so it stops being a black box. Here is real state trimmed down to a single resource. The live file also carries a base64 private blob per instance, which is provider bookkeeping you never edit by hand, plus every attribute the API returned, which runs to hundreds of lines.

terraform.tfstate
{
"version": 4,
"terraform_version": "1.15.8",
"serial": 12,
"lineage": "3f9d2a1c-8b47-4e1a-9f2d-5c6b7a8e9d01",
"outputs": {
"web_public_ip": { "value": "54.210.18.33", "type": "string" }
},
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "i-0abc123def4567890",
"ami": "ami-0c7217cdde317cfec",
"instance_type": "t3.micro",
"private_ip": "10.0.1.24",
"subnet_id": "subnet-0f1a2b3c4d5e6f7a8",
"vpc_security_group_ids": ["sg-0aa11bb22cc33dd44"],
"tags": { "Name": "web-server" }
},
"sensitive_attributes": [],
"dependencies": ["aws_security_group.web"]
}
]
}
],
"check_results": null
}

Five fields carry the weight. The version field is the format version of the file itself, currently 4, and has nothing to do with which Terraform release you run. The serial field is a counter that goes up on every write, so a backend can spot a stale client trying to overwrite newer state. The lineage field is a UUID (universally unique identifier, a long random label) minted when the state was first created, and it lets Terraform tell that two files describe two different worlds and refuse to mix them. The attributes object is a snapshot of everything the provider last reported: it is what terraform state show prints, what a plan run with -refresh=false compares against when you deliberately skip the API call, and what the refresh step compares to when deciding something has drifted. The dependencies list records the graph edges, so Terraform still destroys things in the right order after you have deleted the code that described them.

Reading state without touching it

You will read state constantly and edit it rarely. Two commands cover nearly everything. Running terraform state list prints every address Terraform tracks, including data sources, which are prefixed with data., and resources living inside modules. Running terraform state show followed by an address prints one resource's recorded attributes in readable form.

terminal
$ terraform state list
output
data.aws_ami.ubuntu
aws_db_instance.main
aws_instance.web
aws_security_group.web
random_password.db
module.vpc.aws_subnet.private[0]
module.vpc.aws_subnet.private[1]
module.vpc.aws_vpc.this

That ordering is not alphabetical, and the shape of it is worth knowing so you can find things fast. Data sources sort to the top. Then come the resources in the root module, grouped by resource type. Anything nested inside a module sorts last, which is why random_password.db appears above module.vpc even though r comes after m. A long state file ends up reading like a table of contents for your infrastructure.

terminal
$ terraform state show aws_instance.web | head -16
output
# aws_instance.web:
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890"
associate_public_ip_address = true
availability_zone = "us-east-1a"
disable_api_stop = false
disable_api_termination = false
ebs_optimized = false
get_password_data = false
hibernation = false
id = "i-0abc123def4567890"
instance_initiated_shutdown_behavior = "stop"
instance_state = "running"
instance_type = "t3.micro"
ipv6_address_count = 0

Attributes inside one resource are alphabetical, which is why the first sixteen lines are mostly booleans nobody thinks about. The values you usually came for, private_ip and public_ip and tags, sit further down, because a single EC2 instance records around sixty attributes. Even so, this is the first place to look when a plan proposes something that makes no sense. The code is what you asked for, state is what Terraform believes it built, and the difference between the two usually explains the surprise in one glance. When you need this in a script rather than in your eyes, terraform show -json prints the whole thing machine-readable. That is what inventory scripts and policy checks read: Open Policy Agent with its command-line runner Conftest, or Checkov, which scans infrastructure code and plans for insecure settings.

Drift, and why it is a security control

Someone opens the cloud console at 2am to debug an outage, adds an SSH (Secure Shell, the standard remote login protocol) rule so they can get in, and never takes it out. It is the spare key left under the mat after a callout. Reality has now moved away from both your code and your state, and Terraform calls that gap drift. You can go looking for it on purpose with a refresh-only plan, which asks the provider about every tracked object and reports what changed without proposing to fix anything.

terminal
$ terraform plan -refresh-only
output
aws_security_group.web: Refreshing state... [id=sg-0aa11bb22cc33dd44]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
Note: Objects have changed outside of Terraform
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:
# aws_security_group.web has changed
~ resource "aws_security_group" "web" {
id = "sg-0aa11bb22cc33dd44"
name = "web-sg"
+ ingress {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
}
# (8 unchanged attributes hidden)
}
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.

Read that output as an alert, because that is what it is. The 0.0.0.0/0 in there is CIDR notation (Classless Inter-Domain Routing, the standard way to write a range of addresses), and it means every address on the internet, reaching port 22. An attacker with console credentials who widens a security group, attaches a permissive IAM (Identity and Access Management, the service that decides who is allowed to do what) role, swaps an AMI (Amazon Machine Image, the disk image an instance boots from), or turns off a log stream leaves exactly this fingerprint. So does a well-meaning engineer under pressure. Terraform cannot tell you which of the two it was. It can tell you that production changed outside the pipeline, and that is a question most infrastructure teams cannot answer at all.

From there you have two honest exits. If the change was wrong, run a normal terraform apply. The plan shows that ingress rule being removed, and Terraform pushes reality back to what the code says. If the change was legitimate, put it in the code, get it reviewed, then apply. There is a third command, terraform apply -refresh-only, which means accept these new values into state without touching anything real. That is the right move for a computed attribute the provider changed under you. It is the wrong move for a port 22 rule nobody can explain.

Turning drift into an alarm

Nobody runs a drift check by hand every morning. The -detailed-exitcode flag makes the result readable by a machine: 0 means no changes, 1 means the command failed, 2 means there is something to apply. In refresh-only mode, something to apply means updates to state, which means drift. Point a systemd timer at that and drift becomes a nightly alert instead of an incident finding. Systemd is the init system that starts and supervises everything on a modern Linux box, and its timers are the built-in replacement for cron.

/etc/systemd/system/tf-drift.service
[Unit]
Description=Terraform drift check (read-only refresh)
After=network-online.target
Wants=network-online.target
# systemd only accepts a comment on a line of its own, never trailing a
# directive. OnFailure fires when the plan exits 2 (drift) or 1 (error).
OnFailure=tf-drift-alert.service
[Service]
Type=oneshot
User=terraform
WorkingDirectory=/srv/infra/prod
# anything the run writes stays owner-only
UMask=0077
Environment=TF_IN_AUTOMATION=1 TF_INPUT=0
ExecStart=/usr/bin/terraform plan -refresh-only -detailed-exitcode -lock=false -no-color
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/srv/infra/prod
/etc/systemd/system/tf-drift.timer
[Unit]
Description=Nightly Terraform drift check
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
# run it after boot if the machine was off at 03:15
Persistent=true
[Install]
WantedBy=timers.target
terminal
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now tf-drift.timer
$ systemctl list-timers tf-drift.timer
output
Created symlink /etc/systemd/system/timers.target.wants/tf-drift.timer → /etc/systemd/system/tf-drift.timer.
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-07-22 03:21:47 UTC 15h 58min - - tf-drift.timer tf-drift.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.

Exit code 2 marks the unit failed, and that is deliberate. Drift then shows up in systemctl --failed and fires whatever you hang off OnFailure, whether that is a mailer unit, a curl to your webhook, or your alerting agent. If you ever want the opposite behaviour, SuccessExitStatus=2 tells systemd to treat that code as a normal finish. One syntax trap catches almost everyone: systemd only accepts a comment on a line of its own. Put a hash after a directive and the comment becomes part of the value, so OnFailure would quietly end up pointing at a unit name with a sentence glued to the end of it.

About the rest of it. Running with -lock=false means the nightly check never queues behind a real apply and never makes one queue behind it. A plan writes no state, so the worst it can do is read a snapshot mid-apply and report something confusing, which is a fair trade for a job that must never block a deploy. UMask=0077 is in there for the reason in the next section. The unit assumes terraform init has already run in that directory, and that the terraform user picks up cloud credentials from something you did not have to write down, such as an instance role. One thing to test before you trust the pager: Terraform has had a run of bugs where -refresh-only combined with -detailed-exitcode returned 2 while the output said there were no changes, usually triggered by output values whose type shifted underneath it. Change something by hand, run the command, and check the exit code yourself on the version you actually deploy.

The file holds your secrets in clear text

A vault is only as good as the room you leave the key in. Terraform stores every attribute the provider hands back, and some of those attributes are secrets. A database master password, a generated TLS (Transport Layer Security, the encryption behind HTTPS) private key, an access key, a service-account token: they all land in state as ordinary text strings that anyone able to read the file can read. Here is one lifted out with jq, the standard command-line tool for picking values out of JSON.

terminal
$ terraform state pull | jq -r '.resources[] | select(.type == "aws_db_instance") | .instances[].attributes.password'
output
Sup3rS3cret-Prod-2026
terminal
$ ls -l terraform.tfstate terraform.tfstate.backup
output
-rw-r--r-- 1 deploy deploy 24188 Jul 21 11:04 terraform.tfstate
-rw-r--r-- 1 deploy deploy 23904 Jul 21 10:58 terraform.tfstate.backup

Two details there deserve your attention. Terraform creates local state with whatever your umask allows, the umask being the default-permission mask your shell hands to every new file, and on a stock server that produces 0644 rather than 0600. So every account on that host can read your production database password. The second file, terraform.tfstate.backup, holds the previous copy under the same permissions, which also means pulling a secret out of state leaves it sitting in the backup.

terraform.tfstate
{
"mode": "managed",
"type": "random_password",
"name": "db",
"provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
"instances": [
{
"schema_version": 3,
"attributes": {
"bcrypt_hash": "$2a$10$V9kR2mQ8wZ1sN7yTfL0oXeJ3hB6dC4gA5uP.qM8xW2vY0nZ1aS3bK",
"id": "none",
"keepers": null,
"length": 20,
"lower": true,
"numeric": true,
"result": "Ax7q2Vb9zLm4Tn0Rk8Wp",
"special": false,
"upper": true
},
"sensitive_attributes": []
}
]
}

Marking an output sensitive in your code, or a provider marking an attribute sensitive in its own schema, changes what Terraform prints to your terminal and nothing else. Look at result. That is the password, in the file, in clear text, sitting right next to a sensitive_attributes list that is empty. The bcrypt_hash beside it is a one-way hash of the same password, which buys you nothing here, because the plaintext is on the line above.

Never commit state, and rotate if you already did
Add terraform.tfstate, terraform.tfstate.backup, *.tfstate.* and .terraform/ to .gitignore before your first apply. If state was ever committed, deleting it in a later commit fixes nothing, because the secrets live in git history and in every clone and fork that already pulled it. Check with: git log --all --full-history -- terraform.tfstate. If that returns anything at all, treat every credential the file ever held as compromised and rotate it. Then move to a remote backend that encrypts at rest, versions writes, and locks, which the remote state lesson covers.

Renaming and forgetting

Rename aws_instance.web to aws_instance.frontend in your code and Terraform sees one resource that vanished and one that appeared, so it plans a destroy and a create. The ticket still says hook 47, and nobody told the attendant that the label on the counter changed. You fix it by telling Terraform the object is the same one under a new name.

main.tf
# the resource block is now named "frontend"; this teaches state about the rename
moved {
from = aws_instance.web
to = aws_instance.frontend
}
terminal
$ terraform plan
output
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
Terraform will perform the following actions:
# aws_instance.web has moved to aws_instance.frontend
resource "aws_instance" "frontend" {
id = "i-0abc123def4567890"
instance_type = "t3.micro"
# (28 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.

There is a matching block for the opposite job. A removed block lets you stop managing something without destroying it. You delete the resource block, put this in its place, and Terraform drops the object from state while the real thing keeps running and serving traffic.

main.tf
# the resource block for aws_instance.legacy has been deleted from the code;
# this hands the machine off without touching it
removed {
from = aws_instance.legacy
lifecycle {
destroy = false
}
}

Moved and removed blocks arrived in Terraform 1.1 and 1.7, and they are the reviewable way to do state surgery. They live in the code, go through a pull request, and pass the same plan gate as everything else. The keyboard equivalents, terraform state mv and terraform state rm, make the same edits with no artifact anyone can review afterwards. Prefer the blocks. Keep the commands for emergencies.

That last command, terraform state rm, deserves a hard stare, because it destroys nothing. The resource keeps running, keeps billing, keeps holding whatever data it holds, and Terraform stops seeing it entirely. Sometimes that is exactly what you want during a migration. Often it is how orphans are born. Anyone who can run Terraform in your pipeline can pull a resource out of state, then modify that resource freely, and no drift check will ever mention it again, because nothing is tracking it. The machine keeps serving traffic, outside every review and alarm you built. Defend it the boring way: on a schedule, compare terraform state list against the provider's own inventory, whether that is AWS Config (the service that keeps a running record of your resources), a resource group listing, or a tag query, and investigate anything that exists in the cloud but not in state. A resource quietly leaving state is a signal, not a shrug.

Write access to state is write access to everything

Sit with what that file buys an attacker. Change a recorded ID and the next apply operates on a different object than the one you reviewed. Empty the resources array and the next apply rebuilds production alongside the copy already running. Push a forged file with a higher serial and you overwrite everyone else's view of the world. Delete state outright and terraform destroy becomes a no-op while terraform apply duplicates the whole environment. None of that needs cloud credentials. It needs write access to a JSON file.

So treat the state store as a control plane, not a file share. Turn on bucket versioning so a bad write can be rolled back. Encrypt at rest with a key you control. Limit read access to the pipeline identity and one break-glass role, meaning an emergency account that is normally unused and loudly monitored, rather than the whole engineering group. Log access to the state prefix using S3 (Simple Storage Service, Amazon's object store) server access logs or CloudTrail data events, and alert on any write that did not come from CI (continuous integration, your automated build and deploy pipeline). Lock the state so two applies cannot interleave, which the S3 backend does natively with use_lockfile set to true on Terraform 1.10 and later, and which older setups do with a DynamoDB table. And make terraform force-unlock page someone, because the usual reason a person reaches for it is that something has already gone wrong.

Take a copy before you touch anything

Before any state surgery, a move, a removal, a push, an import you are not certain about, take a copy. The command terraform state pull writes the current state to standard output whatever backend you use, so a restorable snapshot is one line away.

terminal
$ sudo install -d -m 700 -o "$(id -un)" /var/backups/terraform
$ terraform state pull > /var/backups/terraform/prod-$(date -u +%Y%m%dT%H%M%SZ).json
$ terraform state pull | jq '{serial, lineage, resources: (.resources | length)}'
output
{
"serial": 12,
"lineage": "3f9d2a1c-8b47-4e1a-9f2d-5c6b7a8e9d01",
"resources": 7
}

That count is resource blocks, not instances. The two subnets in module.vpc are one block holding two instances, which is why the eight addresses in terraform state list show up as seven here. Note the serial and the lineage before you start, and check them again after. If the surgery goes wrong, a terraform state push of that snapshot puts you back exactly where you were. Store the file with the same care as the real thing, because it holds the same passwords, and delete it when you are done. Do not lean on shred to make it unrecoverable: on a journalling filesystem, on copy-on-write filesystems like Btrfs and ZFS, and on any SSD doing wear levelling underneath you, overwriting the visible blocks does not reach the copies the storage layer kept for itself. Full-disk encryption on the backup host is the control that actually holds.

Quick check
01Someone adds an inbound rule allowing 0.0.0.0/0 on port 22 to a security group by hand in the console. Your Terraform code still declares only port 443. What does the next plain terraform plan do?
Incorrect — State is not the only input. The refresh step re-reads the real object from the provider API before anything is compared.
Correct — Your config is the target, freshly refreshed state is the starting point, and the plan is the gap between them.
Incorrect — Terraform never edits your configuration. The code is the one thing only you change.
Incorrect — An ingress rule can be updated in place, so nothing here forces a destroy and recreate.
02You permanently lose your terraform.tfstate file with no backup, but your .tf code and the real running infrastructure are both untouched. On the next terraform apply, what happens?
Incorrect — without state Terraform holds no ticket linking a code address to a real object, so it does not adopt existing resources automatically.
Incorrect — it does not refuse; with no state it treats the world as empty and proceeds to build.
Incorrect — it cannot destroy what it has no record of; the originals keep running while duplicates appear.
Correct — state is the memory that maps code to real IDs, so losing it makes Terraform build everything again.
03A teammate says the database password is safe because the output exposing it is marked sensitive = true and the state's sensitive_attributes list is empty. You run terraform state pull | jq to read the password and the plaintext prints. Why?
Correct — marking a value sensitive changes what is printed to the screen and nothing about how it is stored.
Incorrect — Terraform does not encrypt state at rest; state pull just fetches the JSON, secrets included.
Incorrect — even when a field is marked, its value stays in state in clear text; the marking affects display, not storage.
Incorrect — there is no per-field access control in state; anyone able to read the file reads every attribute in it.

Try this

Run terraform state list on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: never commit state, and rotate if you already did. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related