Terraform · lesson 3 of 17
Beginner
7- 1 Terraform Fundamentals (IaC)
- 2 Terraform Workflow (init → validate → plan → apply)
- 3 Terraform State (tfstate, remote state, locking)
- 4 Terraform Variables and Outputs
- 5 Providers and Resources (the building blocks of Terraform)
- 6 Terraform Locals: Computed Values Without Repetition
- 7 Terraform Modules (reuse, composition, and clean structure)
Intermediate
7- 8 Terraform count and for_each (repeat resources safely)
- 9 The lifecycle Block: prevent_destroy, create_before_destroy, ignore_changes
- 10 Data Sources and Dependencies (read existing infrastructure)
- 11 sensitive = true: What It Hides, and What It Doesn't
- 12 Remote State Safety (locking, drift, and recovery)
- 13 Workspaces and Environments (dev/stage/prod patterns)
- 14 Terraform CI/CD with GitHub Actions (plan/apply)
Advanced
3Also part of Terraform
4Terraform State (tfstate, remote state, locking)
What terraform.tfstate holds, what happens when it is lost or a resource is renamed, and how state mv, state rm and moved blocks fix it, with real output.
By SkillByExample · Updated
Terraform’s state file is its memory: a record of which real object belongs to which resource
block. Plans compare against it, and losing or misaligning it is behind most Terraform
surprises. The examples use Terraform 1.16.5 and hashicorp/random 3.9.1 with this config:
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
resource "random_pet" "app" {
prefix = "app"
}
resource "random_id" "bucket_suffix" {
byte_length = 4
}
What’s in the state file
After terraform init and terraform apply:
$ terraform state list
random_id.bucket_suffix
random_pet.app
$ terraform state show -no-color random_id.bucket_suffix
# random_id.bucket_suffix:
resource "random_id" "bucket_suffix" {
b64_std = "qiAaCg=="
b64_url = "qiAaCg"
byte_length = 4
dec = "2854230538"
hex = "aa201a0a"
id = "qiAaCg"
}
$ cat terraform.tfstate
{
"version": 4,
"terraform_version": "1.16.5",
"serial": 3,
"lineage": "ccd8bfc4-50c9-3f43-7cf0-a1e549a7596d",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "random_id",
"name": "bucket_suffix",
"provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"b64_std": "qiAaCg==",
"b64_url": "qiAaCg",
"byte_length": 4,
"dec": "2854230538",
"hex": "aa201a0a",
"id": "qiAaCg",
"keepers": null,
"prefix": null
},
"sensitive_attributes": [],
"identity_schema_version": 0
}
]
},
{
"mode": "managed",
"type": "random_pet",
"name": "app",
"provider": "provider[\"registry.terraform.io/hashicorp/random\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "app-proper-scorpion",
"keepers": null,
"length": 2,
"prefix": "app",
"separator": "-"
},
"sensitive_attributes": [],
"identity_schema_version": 0
}
]
}
],
"check_results": null
}
Your two lines of random_id config turned into six attributes in state show (the JSON also
records the unset keepers and prefix as null). Every value the
provider reported back gets stored, including ones you never set, like hex and dec. The
lineage is a unique ID for this state’s history, and serial goes up on every write.
Terraform uses both to refuse an older copy of the state overwriting a newer one. Use
state list and state show to read state. Don’t open the JSON in an editor.
Losing the state file
$ mv terraform.tfstate ../saved.tfstate
$ terraform plan -no-color | grep -E "^ #|Plan:"
# random_id.bucket_suffix will be created
# random_pet.app will be created
Plan: 2 to add, 0 to change, 0 to destroy.
$ mv ../saved.tfstate terraform.tfstate
Without state, Terraform has no record of creating anything, so it proposes creating everything again. With random values that just means new values. With cloud resources, it means a second VPC next to the first one, or a “bucket already exists” error halfway through an apply, while the originals keep running with nothing managing them. Moving the file back restores the mapping.
Renaming a resource block
Rename resource "random_pet" "app" to resource "random_pet" "web" and plan:
$ terraform plan -no-color
random_pet.app: Refreshing state... [id=app-proper-scorpion]
random_id.bucket_suffix: Refreshing state... [id=qiAaCg]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
- destroy
Terraform will perform the following actions:
# random_pet.app will be destroyed
# (because random_pet.app is not in configuration)
- resource "random_pet" "app" {
- id = "app-proper-scorpion" -> null
- length = 2 -> null
- prefix = "app" -> null
- separator = "-" -> null
}
# random_pet.web will be created
+ resource "random_pet" "web" {
+ id = (known after apply)
+ length = 2
+ prefix = "app"
+ separator = "-"
}
Plan: 1 to add, 0 to change, 1 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
The config is identical apart from the label, but state stores objects by address. The
address random_pet.app disappeared and random_pet.web appeared, so Terraform plans a destroy
and a create. On a database, a refactor that only renamed a block would delete the data.
The fix: a moved block
moved {
from = random_pet.app
to = random_pet.web
}
$ terraform apply -auto-approve -no-color
random_id.bucket_suffix: Refreshing state... [id=qiAaCg]
random_pet.web: Refreshing state... [id=app-proper-scorpion]
Terraform will perform the following actions:
# random_pet.app has moved to random_pet.web
resource "random_pet" "web" {
id = "app-proper-scorpion"
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
random_id.bucket_suffix
random_pet.web
Same object, same id, new address. A moved block goes through code review and plans like
any other change. That’s why it’s the preferred way to rename. Once every copy of the state has
been applied with it, you can delete the block.
Remote state (configuration only)
Each person’s local terraform.tfstate is fine for learning. A team needs one shared state with
locking, so two applies can’t interleave. This S3 backend block is shown as configuration
only. It needs an AWS account, so there’s no output here.
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "envs/prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
use_lockfile makes the S3 backend write its own lock object. That replaces the DynamoDB table
older setups used. Remote state safety shows locking for
real, using the local backend, which locks the same way.
Practice
1. Rename random_pet.web to random_pet.frontend again, this time with the
terraform state mv command instead of a moved block.
Solution
Delete the moved block, rename the resource block in main.tf, then:
$ terraform state mv -no-color random_pet.web random_pet.frontend
Move "random_pet.web" to "random_pet.frontend"
Successfully moved 1 object(s).
$ terraform plan -no-color | tail -3
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
state mv changes the state immediately, outside any plan, so nobody reviews it. That’s fine
when you’re working alone. In a team, a moved block goes through code review.
2. Make Terraform forget random_id.bucket_suffix without destroying it, then plan.
Solution
$ terraform state rm -no-color random_id.bucket_suffix
Removed random_id.bucket_suffix
Successfully removed 1 resource instance(s).
$ terraform plan -no-color | grep -E "^ #|Plan:"
# random_id.bucket_suffix will be created
Plan: 1 to add, 0 to change, 0 to destroy.
state rm drops the record and leaves the real object alone. Because the block is still in the
config, the next plan creates a new one. To stop managing a resource, delete its block too (or
use a removed block). To bring the old object back under management, use
terraform import.
3. List the directory and compare the serial in terraform.tfstate with the one in
terraform.tfstate.backup.
Solution
$ ls
main.tf
terraform.tfstate
terraform.tfstate.1791385451.backup
terraform.tfstate.1791385453.backup
terraform.tfstate.backup
$ grep serial terraform.tfstate terraform.tfstate.backup
terraform.tfstate: "serial": 6,
terraform.tfstate.backup: "serial": 3,
Since the backup was taken, three writes happened: the moved apply, state mv and state rm.
Each one bumped the serial. Each state subcommand also leaves a backup named by Unix timestamp, one for
state mv and one for state rm here, so a mistaken state rm can be undone by copying its
file back. Two commands in the same second share one timestamp, so the second overwrites the
first’s backup.
Next: variables and outputs, which make one configuration serve many environments.