Anti-Pattern #
State is the Terraform component that’s easiest to break and hardest to fix. Mistakes in state management often don’t show their impact immediately, but pile up into big problems when it’s too late ā undetected drift, orphaned resources wasting money, or corrupted state that makes the entire infrastructure unmanageable. This article compiles the anti-patterns most commonly found in the field.
flowchart LR
A["š Manual State\nEditing"] -->|results in| B["š„ Corrupted\nState"]
C["š No\nLocking"] -->|results in| D["ā ļø Race\nCondition"]
E["š State\nin Git"] -->|results in| F["š Secret\nLeak"]
style A fill:#e74c3c,stroke:#c0392b,color:#fff
style B fill:#c0392b,stroke:#96281b,color:#fff
style C fill:#e67e22,stroke:#d35400,color:#fff
style D fill:#d35400,stroke:#a04000,color:#fff
style E fill:#f1c40f,stroke:#d4ac0d,color:#000
style F fill:#d4ac0d,stroke:#b7950b,color:#000Anti-Pattern 1: State in Git #
This is the most dangerous anti-pattern and is still very common, especially among teams just starting with Terraform.
# ANTI-PATTERN: Committing terraform.tfstate to Git
git add terraform.tfstate
git commit -m "update state"
git push
# The problems it causes:
# 1. SECRET LEAK
# State can contain passwords, API keys, and private keys in plaintext.
# Anyone with repository access can read them.
# Even after removal from the latest commit, the secrets remain in Git history.
# 2. UNSOLVABLE MERGE CONFLICTS
# The state file is auto-generated JSON.
# Merge conflicts in state can't be resolved manually
# without risking breaking the JSON structure Terraform expects.
# 3. NO LOCKING
# Git provides no locking mechanism to prevent
# two people pushing state simultaneously.
# CORRECT: Use a remote backend and exclude state from Git
echo "*.tfstate" >> .gitignore
echo "*.tfstate.backup" >> .gitignore
Anti-Pattern 2: Editing the State File Manually #
The state file is a JSON file, and text editors can open it. But editing it manually is a recipe for corrupted state.
# ANTI-PATTERN: Editing terraform.tfstate with a text editor
vim terraform.tfstate # ā Never do this
# Risks:
# - A typo that breaks the JSON structure ā state can't be read
# - Forgetting to update "serial" ā Terraform rejects the state as stale
# - Inconsistent references between resources
# CORRECT: Use terraform state subcommands for all operations
terraform state list # List all resources
terraform state show aws_instance.web # View resource details
terraform state rm aws_instance.web # Remove from state (without destroying)
terraform state mv aws_instance.web aws_instance.web_server # Rename
terraform state pull > backup.tfstate # Back up state
terraform state push modified.tfstate # Push state (careful)
Anti-Pattern 3: Monolithic State for Large Infrastructure #
Storing your entire infrastructure ā production, staging, networking, compute, database ā in a single state file is a pattern that doesn’t scale.
PROBLEMS WITH MONOLITHIC STATE:
Performance:
Plan must query ALL resources from the provider APIs
ā The more resources, the slower the plan
ā 200+ resources can take 5-10 minutes just to plan
Blast Radius:
One apply failing midway can affect
all resources ā networking, compute, database at once
ā Higher risk, wider impact
Collaboration:
Only one person can apply at a time
(because of locking)
ā A bottleneck in active teams
Security:
Everyone needs access to one state
ā Can't restrict access per component
SOLUTION: Split by layer or component
state/networking/ ā VPC, subnets, routing
state/compute/ ā EC2, ASG, load balancers
state/database/ ā RDS, ElastiCache
state/security/ ā IAM, security groups
flowchart TD
subgraph MONO["ā Monolithic State"]
direction TB
M1["šļø One State File\nfor Everything"]
M2["Networking\n(VPC, Subnet)"]
M3["Compute\n(EC2, ASG)"]
M4["Database\n(RDS)"]
M5["Security\n(IAM, SG)"]
M1 --- M2
M1 --- M3
M1 --- M4
M1 --- M5
end
subgraph SPLIT["ā
Separate States"]
direction LR
S1["state/networking/\nš Network Team"]
S2["state/compute/\nš Backend Team"]
S3["state/database/\nš DBA Team"]
S4["state/security/\nš Security Team"]
end
MONO -->|"Split per\nlayer/component"| SPLIT
style MONO fill:#ffebee,stroke:#c62828
style SPLIT fill:#e8f5e9,stroke:#2e7d32
style S1 fill:#e3f2fd,stroke:#1565c0
style S2 fill:#fff3e0,stroke:#e65100
style S3 fill:#f3e5f5,stroke:#6a1b9a
style S4 fill:#fce4ec,stroke:#c62828Anti-Pattern 4: No State Locking #
Running Terraform without state locking in a team environment is a ticking time bomb.
# ANTI-PATTERN: S3 backend without DynamoDB for locking
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
# dynamodb_table is not configured ā NO LOCKING
}
}
# Consequences:
# Developer A and B apply simultaneously
# ā Both read the old state
# ā Both write different new states
# ā One overwrites the other ā inconsistent state
# CORRECT: Always configure locking
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # ā Required
}
}
Anti-Pattern 5: Ignoring Orphaned Resources #
An orphaned resource is a resource that exists in the cloud but isn’t in Terraform state ā usually because it was created outside Terraform, or because state was once modified improperly.
# How to detect orphaned resources:
# 1. List all resources in state
terraform state list > state-resources.txt
# 2. List all actual resources in the cloud (example for AWS)
aws ec2 describe-instances \
--query 'Reservations[].Instances[?State.Name==`running`].InstanceId' \
--output text > cloud-resources.txt
# 3. Compare ā resources in the cloud but not in state = orphaned
# Impact of orphaned resources:
# - Waste money because they run undetected
# - No governance ā anyone can create resources without review
# - Invisible drift ā state doesn't reflect reality
# SOLUTION: Import the orphaned resource into Terraform
terraform import aws_instance.recovered_server i-0abcdef1234567890
Anti-Pattern 6: Sensitive Values in Unprotected Outputs #
State stores all output values ā including sensitive ones ā in plaintext if they aren’t marked correctly.
# ANTI-PATTERN: Sensitive output without sensitive = true
output "database_password" {
value = aws_db_instance.main.password
# The password is stored in plaintext in state and shown in the terminal
}
# CORRECT: Mark the sensitive output
output "database_password" {
value = aws_db_instance.main.password
sensitive = true
# Hidden from the terminal output
# But still stored in state ā make sure the state file is secured
}
Healthy State Management Checklist #
STATE MANAGEMENT CHECKLIST:
STORAGE:
ā” State stored in a remote backend (S3, GCS, Terraform Cloud)
ā” Encryption at rest enabled
ā” Versioning enabled on the S3 bucket
LOCKING:
ā” State locking configured (DynamoDB for S3)
ā” CI/CD pipelines use concurrency control
SECURITY:
ā” terraform.tfstate isn't in the Git repository
ā” *.tfstate is in .gitignore
ā” State access is restricted with IAM policies
ā” Sensitive outputs are marked with sensitive = true
ORGANIZATION:
ā” State split per environment (dev/staging/production)
ā” State split per component if the infrastructure is large
ā” No unmanaged orphaned resources
OPERATIONS:
ā” State backed up before risky operations
ā” No manual edits to the state file
ā” All state operations go through terraform state subcommands
Anti-Pattern 7: Ignored State Drift #
State drift happens when the actual cloud condition differs from what’s recorded in state. Drift can occur from manual console changes, auto-scaling, or provider bugs. Ignoring drift is a recipe for production incidents.
flowchart TD
A["Terraform State\n(instance_type: t3.micro)"] --> B{"Drift?"}
B -->|"No drift"| C["terraform plan\n= No changes ā
"]
B -->|"Drift exists"| D["Cloud Reality\n(instance_type: t3.large)"]
D --> E["terraform plan\ndetects the change"]
D --> F["Drift ignored ā"]
F --> G["State gets more\ninconsistent"]
G --> H["Production Incident š„"]
style A fill:#e3f2fd,stroke:#1565c0
style D fill:#fff3e0,stroke:#e65100
style F fill:#ffebee,stroke:#c62828
style H fill:#c0392b,stroke:#96281b,color:#fff# Detect drift with a refresh-only plan
terraform plan -refresh-only
# Compares state with cloud reality WITHOUT making a change plan
# The output shows what differs
# Example drift detection output:
# aws_instance.web have changed
# ~ instance_type: "t3.micro" => "t3.large"
# Meaning: someone changed the instance type in the AWS Console
# Drift handling options:
# 1. Accept the change (update state to reflect reality)
terraform apply -refresh-only
# 2. Revert to the configuration (overwrite the manual change)
terraform apply # Will revert to t3.micro
# Automate drift detection in CI/CD (run hourly/on a schedule)
Anti-Pattern 8: Reckless terraform state push
#
terraform state push is the most dangerous command in the entire Terraform CLI. It can overwrite the active state without any validation.
# ANTI-PATTERN: Pushing state without validation
terraform state push backup.tfstate
# Immediately overwrites the active state ā no confirmation
# If the backup is stale, newly created resources vanish from state
# RISKS:
# - Old state overwrites new state ā new resources become orphaned
# - State from another environment gets pushed to production
# - Serial conflicts aren't properly checked
# EVEN MORE DANGEROUS: Push with -force
terraform state push -force backup.tfstate
# Ignores all safety checks including serial and lineage
# Can force state from another workspace onto the current one
# CORRECT: Only use state push after strict verification
# 1. Back up the current state
terraform state pull > current-state-backup.tfstate
# 2. Verify the backup to be pushed is still relevant
cat backup.tfstate | jq '.serial'
cat current-state-backup.tfstate | jq '.serial'
# Make sure the backup's serial > the current one OR you're intentionally rolling back
# 3. Verify the lineage matches
cat backup.tfstate | jq '.lineage'
cat current-state-backup.tfstate | jq '.lineage'
# Lineage MUST match ā different lineage = different workspace
# 4. Push with verbose output
terraform state push backup.tfstate
# Verify: terraform plan should show "No changes"
Anti-Pattern 9: Using -target as a Routine Solution
#
-target is powerful for solving specific problems, but using it routinely creates inconsistent state.
# ANTI-PATTERN: Always using -target for applies
terraform apply -target=aws_instance.web
# Only applies one resource ā other changed resources are ignored
# State is now only updated for the targeted resource
# Other resources that should have changed remain on the old state
# Cumulative impact if done repeatedly:
# Mixed state: some resources updated, some not
# terraform plan keeps showing changes that never complete
# The gap between state and configuration keeps growing
# CORRECT: Use -target only for:
# 1. Debugging ā you want to see the plan for a specific resource
terraform plan -target=aws_instance.web
# 2. Recovery ā a specific resource needs to be applied first due to dependencies
terraform apply -target=aws_security_group.web_sg
terraform apply # Apply the rest once the dependency is satisfied
# 3. NOT as a way to avoid errors in other resources
# ā Fix the error, don't skip it with -target
Summary #
- State in Git is the most dangerous anti-pattern ā secret leaks, no locking, unsolvable merge conflicts.
- Never edit state manually ā always use
terraform statesubcommands for all operations.- Monolithic state doesn’t scale ā slow plans, large blast radius, hindered collaboration. Split it by layer or component.
- Locking isn’t optional ā without a DynamoDB table in the S3 backend, race conditions and corrupted state are only a matter of time.
- Orphaned resources waste money and create undetected drift ā audit regularly and import what you find.
- Mark sensitive outputs with
sensitive = trueā although they still remain in state, at least they don’t appear in the terminal.- State drift must be actively monitored ā use scheduled
terraform plan -refresh-onlyto detect manual cloud changes.terraform state pushis the most dangerous command ā always verify serial, lineage, and backup before pushing.- Don’t use
-targetas a routine solution ā use it only for debugging and recovery, not to avoid errors.