Terraform #02 — State Management: Remote Backend, Locking & Import
A deep dive into the state file, S3 remote backend with locking, importing existing resources, and handling drift safely.
Goal: Understand the state file — the heart of Terraform. This is the topic that causes the most production pain. Know it well before you need it.
This is the most important module. State questions come up constantly in technical interviews about Terraform.
2.1. What is state and why does it exist?
State (terraform.tfstate) is a JSON file that maps between:
Config (.tf) State (.tfstate) Reality (Cloud)
─────────── ───────────────── ───────────────
aws_instance.web ◄──► id: i-0abc123 ◄──► Actual EC2 instance
State answers three critical questions:
- Which real-world object does this config resource correspond to? (config → ID mapping)
- Metadata — dependency ordering, auxiliary data.
- Performance — cached attributes so Terraform doesn't have to query the entire cloud every plan.
❗ Without state, Terraform doesn't know what it created. It would try to create everything again (duplicates), and wouldn't know what to update or destroy.
2.2. The problem with local state
By default, state lives in a local terraform.tfstate file. This is fine for solo learning, but breaks down fast for teams and production:
| Problem | Consequence |
|---|---|
| No locking | Two people apply simultaneously → state corruption |
| Not shareable | Everyone has their own state → conflicts |
| Machine dies = state gone | No recovery — have to re-import everything |
| Contains secrets in plaintext | DB passwords, private keys... exposed if accidentally committed |
| No version history | No rollback if something goes wrong |
⚠️ Never commit
terraform.tfstateto Git. It contains secrets and causes merge conflicts.
2.3. Remote backend — the solution
The backend controls where state is stored and how operations run.
┌─────────────────────────────────────────┐
│ Remote Backend (S3) │
Dev A ──►│ s3://my-tfstate/prod/terraform.tfstate │◄── Dev B
│ │
│ + Lock (DynamoDB) ← only one apply │
│ + Versioning ← rollback anytime │
│ + Encryption ← secrets are safe │
└─────────────────────────────────────────┘
S3 backend (AWS) — the most common choice
terraform {
backend "s3" {
bucket = "my-company-tfstate"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # for locking (pre-1.10)
# Terraform 1.10+ can use use_lockfile = true (S3-native locking, no DynamoDB needed)
}
}Terraform 1.10+: The S3 backend supports
use_lockfile = true— it locks using S3 conditional writes, no DynamoDB required. DynamoDB locking is still widely used in older codebases.
Other common backends
| Backend | Used for | Locking |
|---|---|---|
s3 | AWS | DynamoDB or S3 lockfile |
azurerm | Azure | Blob lease (automatic) |
gcs | GCP | automatic |
kubernetes | K8s secrets | automatic |
remote / cloud | HCP Terraform / Enterprise | automatic |
http | GitLab managed state... | backend-dependent |
local | Personal use (default) | file lock |
⚠️ The chicken-and-egg problem
To use an S3 backend, you need an S3 bucket + DynamoDB table. But if you create them with Terraform, where does that state go?
Standard bootstrap approach:
- Create the bucket + table using Terraform with local state (a separate "bootstrap" project).
- Or create them manually with the AWS CLI.
- All other projects then point their backend at these resources.
- (Optional) Migrate the bootstrap project's own state into the bucket with
terraform init -migrate-state.
2.4. Partial backend configuration
You can't use variables inside a backend block — Terraform evaluates backends before variables are loaded. The workaround: declare the backend type only, pass the rest at init time.
# backend.tf — only declare the type
terraform {
backend "s3" {}
}# Pass the details at init
terraform init -backend-config="bucket=my-tfstate" \
-backend-config="key=prod/terraform.tfstate" \
-backend-config="region=us-east-1"
# Or use a file:
terraform init -backend-config=backend-prod.hclbackend-prod.hcl:
bucket = "my-tfstate"
key = "prod/terraform.tfstate"
region = "us-east-1"This is the key technique for using one codebase across multiple environments (Module 07).
2.5. State manipulation commands
| Command | What it does | When you'd use it |
|---|---|---|
terraform state list | List all resources | See what's in state |
terraform state show <addr> | Show details of one resource | Debug attribute values |
terraform state mv <src> <dst> | Rename a resource in state | Refactoring, renaming |
terraform state rm <addr> | Remove from state (does NOT delete the real resource) | "Unmanage" something |
terraform state pull | Print state to stdout | Backup, inspect |
terraform state push | Upload state (DANGEROUS) | Emergency recovery |
terraform force-unlock <ID> | Release a stuck lock | After a crash mid-apply |
terraform refresh | Sync state with reality (deprecated, use -refresh-only) | Detect drift |
⚠️ State surgery warnings
state rmfollowed byapplycan make Terraform create a duplicate — it now thinks the resource doesn't exist.state pushwith the wrong state = you've broken everything. Alwaysstate pull > backup.jsonfirst.- Prefer
movedblocks (Module 09) overstate mvwhen possible — they're version-controlled and reviewable.
2.6. Import — bringing existing resources under management
This comes up constantly: infrastructure was created manually (ClickOps), and now you want Terraform to manage it.
Old way: terraform import command
# terraform import <resource_address> <real_id>
terraform import aws_s3_bucket.legacy my-existing-bucketAfter this, you have to manually write the resource "aws_s3_bucket" "legacy" {} block to match reality, then run plan until you see "No changes."
New way (Terraform 1.5+): import block — recommended
import {
to = aws_s3_bucket.legacy
id = "my-existing-bucket"
}
resource "aws_s3_bucket" "legacy" {
bucket = "my-existing-bucket"
# ... other attributes
}The real time-saver: Terraform 1.5+ can generate the resource block for you:
terraform plan -generate-config-out=generated.tfTerraform writes the resource block based on what's actually deployed. You clean it up. Much faster when importing dozens of resources at once.
2.7. Drift — when reality diverges from state
Drift happens when someone modifies a resource outside Terraform (e.g. changes a security group rule in the AWS console).
# Detect drift without changing anything
terraform plan -refresh-only
# Accept the new reality into state (without touching infrastructure)
terraform apply -refresh-onlyWhen you run a normal plan, Terraform will want to bring reality back to match config — it'll plan to undo the manual change. That's a feature, not a bug. Config is the source of truth.
2.8. State security
- Encryption at rest:
encrypt = true(S3) + enable SSE/KMS on the bucket. - Encryption in transit: backends use HTTPS.
- Restrict bucket access with tight IAM policies.
- Enable versioning on the bucket — rollback is possible when you need it.
- ⚠️ State always contains secrets in plaintext — even
sensitivevalues. Securing state is non-negotiable. - Terraform 1.x State Encryption (available in OpenTofu, in development for Terraform) — encrypts values inside the state file itself.
2.9. Hands-on Labs
Go to hands-on/ and follow LAB.md:
- Lab 1 — Inspect local state:
state list/show, read the JSON structure. - Lab 2 —
state mvandstate rm(safe, using the local provider). - Lab 3 — Import an existing resource using the
importblock. - Bootstrap — Sample files to create an S3 backend + DynamoDB lock table.
✅ Module 02 completion criteria
- Can explain why you shouldn't commit state to Git or use local state for a team.
- Can configure an S3 backend with locking.
- Understand partial backend config and why it matters for multi-environment setups.
- Know the difference between
state rm(unmanage) anddestroy(delete). - Can import a resource using the
importblock and-generate-config-out. - Can detect and handle drift using
-refresh-only.
➡️ Next: Module 03 — Variables & Outputs