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:#000

Anti-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:#c62828

Anti-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 state subcommands 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-only to detect manual cloud changes.
  • terraform state push is the most dangerous command — always verify serial, lineage, and backup before pushing.
  • Don’t use -target as a routine solution — use it only for debugging and recovery, not to avoid errors.

← Previous: Import Ā  Next: What is a Variable? →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact