TerraformPilot

DevOps

Fix Terraform Error - Perpetual Diff on Every Plan

How to fix Terraform plan showing changes on every run caused by computed attributes, API normalization, and provider bugs. Step-by-step solutions with code.

LLuca Berton3 min read

Quick Answer

#

Terraform shows changes on every plan because the cloud provider modifies values after creation (normalization), or your config uses computed/dynamic values. Use ignore_changes in a lifecycle block for provider-side normalization, jsonencode() for policy formatting issues, or explicitly set default values that match the provider's behavior.

The Error

#

Every time you run terraform plan, it shows changes even though nothing has actually changed:

# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
    ~ egress {
        - cidr_blocks = ["0.0.0.0/0"] -> null
        - from_port   = 0 -> null
      }
  }
 
Plan: 0 to add, 1 to change, 0 to destroy.
# aws_iam_role.main will be updated in-place
~ resource "aws_iam_role" "main" {
    ~ assume_role_policy = jsonencode(
        ~ {
            ~ Statement = [
                ~ {
                    + Sid = ""
                  }
              ]
          }
      )
  }

This isn't technically an "error" — Terraform succeeds — but it makes plan unreliable and can cause unnecessary updates in CI/CD pipelines.

What Causes This

#

1. API Normalization

#

Cloud providers modify values after creation. AWS might reorder JSON keys in IAM policies, add default fields, or normalize CIDR blocks. Terraform compares your config against the normalized API response and sees a "diff."

2. Default Values Added by the Provider

#

The provider automatically adds attributes you didn't specify (like default egress rules on security groups), then detects them as differences on the next plan.

3. JSON Key Ordering

#

Hand-written JSON in policies may have different key ordering than what the API returns:

// Your config
{"Effect": "Allow", "Action": "s3:*"}
 
// API returns
{"Action": "s3:*", "Effect": "Allow"}

4. Computed Attributes

#

Some attributes are computed server-side and change on every read (like last_modified timestamps or computed tags).

5. Provider Bugs

#

Sometimes perpetual diffs are provider bugs — the provider incorrectly detects changes.

How to Fix It

#

Solution 1: Use ignore_changes for Provider-Normalized Attributes

#
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
 
  lifecycle {
    ignore_changes = [
      ami,                    # AMI updates handled separately
      tags["LastUpdated"],    # Timestamp tag set by Lambda
      user_data,              # Base64 encoding differences
    ]
  }
}

For security group egress rules that the provider adds automatically:

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = var.vpc_id
 
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
 
  lifecycle {
    ignore_changes = [egress]
  }
}

Solution 2: Use jsonencode() for Consistent Policy Formatting

#

Replace hand-written JSON with jsonencode() so Terraform controls the formatting:

# BAD — hand-written JSON, key order may differ from API
resource "aws_iam_role" "main" {
  assume_role_policy = <<-POLICY
  {
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "ec2.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }]
  }
  POLICY
}
 
# GOOD — jsonencode produces consistent output
resource "aws_iam_role" "main" {
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

Or use the IAM policy data source:

data "aws_iam_policy_document" "assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}
 
resource "aws_iam_role" "main" {
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

Solution 3: Explicitly Set Default Values

#

If the provider adds default values, set them explicitly in your config:

# BAD — provider adds default egress, causing diff
resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = var.vpc_id
}
 
# GOOD — explicitly define the default egress rule
resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = var.vpc_id
 
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

For EKS clusters with tags:

resource "aws_eks_cluster" "main" {
  name     = "production"
  role_arn = aws_iam_role.eks.arn
 
  vpc_config {
    subnet_ids = var.subnet_ids
  }
 
  # EKS adds its own tags — ignore them
  lifecycle {
    ignore_changes = [
      tags["kubernetes.io/cluster/production"],
    ]
  }
}

Solution 4: Use replace_triggered_by for Intentional Replacements

#

If a resource should only update when specific inputs change:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
 
  lifecycle {
    replace_triggered_by = [
      var.ami_id,  # Only replace when AMI changes
    ]
    ignore_changes = [
      tags,
      user_data,
    ]
  }
}

Solution 5: Refresh State to Sync with Reality

#

Sometimes the diff is caused by stale state:

# Refresh state from the actual infrastructure
terraform apply -refresh-only
 
# Review the changes and approve
# This updates state without changing infrastructure

Solution 6: Check for Provider Bugs

#

If none of the above work, it may be a provider bug:

# Check provider version
terraform version
 
# Upgrade to latest provider
terraform init -upgrade
terraform plan

Search the provider's GitHub issues:

  • AWS Provider: github.com/hashicorp/terraform-provider-aws/issues
  • Search: perpetual diff or plan not empty + resource type

Common Perpetual Diff Scenarios

#
ResourceAttributeCauseFix
aws_security_groupegressDefault egress rule added by AWSSet egress explicitly or ignore_changes
aws_iam_roleassume_role_policyJSON key reorderingUse jsonencode()
aws_lambda_functionlast_modifiedTimestamp changesignore_changes = [last_modified]
aws_autoscaling_groupdesired_capacityASG scales independentlyignore_changes = [desired_capacity]
aws_ecs_task_definitioncontainer_definitionsJSON formattingUse jsonencode()
aws_eks_clustertagsKubernetes adds tagsignore_changes = [tags]
azurerm_*VariousCase sensitivity differencesMatch exact casing from API

Troubleshooting Checklist

#
  1. ✅ What attribute shows the diff? (terraform plan output)
  2. ✅ Is it a JSON formatting issue? → Use jsonencode()
  3. ✅ Is the provider adding default values? → Set them explicitly
  4. ✅ Is it a computed attribute? → Use ignore_changes
  5. ✅ Does terraform apply -refresh-only resolve it?
  6. ✅ Does upgrading the provider fix it?
  7. ✅ Is there a known provider issue on GitHub?

Prevention Tips

#
  • Use jsonencode() for all JSON — consistent formatting prevents ordering diffs
  • Set known defaults explicitly — don't rely on provider defaults
  • Use ignore_changes sparingly — only for genuinely external changes
  • Pin provider versions — avoid surprise behavior changes across versions
  • Run terraform plan in CI — catch perpetual diffs before they reach production
#

Conclusion

#

Perpetual diffs make terraform plan unreliable — you can't trust that "1 to change" means a real change. Fix the root cause: use jsonencode() for policies, set defaults explicitly, and use ignore_changes for attributes modified outside Terraform. A clean plan with zero changes is the goal.

#Terraform#Troubleshooting#DevOps#Error Fix#Infrastructure as Code

Share this article