Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 072 / 100

Common Terraform errors and fixes: a step-by-step guide

Errors you will see — and the fix.

Expona Admin8 min read

Introduction

When you're managing infrastructure at scale, Terraform will eventually throw errors that halt your deployment pipeline. The difference between a junior and senior engineer isn't avoiding these errors—it's knowing exactly why they occur and how to fix them without panicking or nuking your state file. This guide covers common Terraform errors and fixes: a step-by-step guide to the four most painful scenarios we've encountered across production environments, with concrete solutions you can apply immediately.

Error acquiring the state lock — another run is active or crashed

This error surfaces when Terraform detects that another operation holds the state lock. In a CI/CD environment, this usually means a previous run crashed, timed out, or a team member's terminal hung mid-apply. The panic response is often to force-unlock immediately. Don't do that yet.

Why it happens

Terraform uses state locking (via DynamoDB on AWS, blob storage on Azure, or a local file) to prevent concurrent modifications. When a run crashes before releasing the lock, the next operation finds it and refuses to proceed. Force-unlocking immediately risks corrupting your state if the previous run is still writing.

The safe fix

Step 1: Identify the lock holder. Check your state backend logs and CI/CD pipeline history. If you're using DynamoDB:

hcl
aws dynamodb scan \
  --table-name terraform-locks \
  --region us-east-1

Look for an entry with Expiration in the past or a PID that no longer exists.

Step 2: Wait and verify the process is truly dead. Check your CI logs. If a run started 10 minutes ago and the logs ended abruptly, it's safe to proceed. If the run completed but the lock wasn't released (rare with modern Terraform), move to Step 3.

Step 3: Force-unlock as a last resort. Only force-unlock if you're absolutely certain the process is dead and your state is not being modified:

hcl
terraform force-unlock <LOCK_ID>

Find the LOCK_ID from your DynamoDB scan output or the error message itself.

Step 4: Run a plan to verify state integrity. Before applying any changes:

hcl
terraform plan -out=tfplan

If the plan succeeds and looks sensible, you're safe. If it shows unexpected deletions or changes, your state may be corrupted—restore from backup before applying.

Prevention in CI/CD

Set a timeout on your Terraform runs and configure auto-unlock cleanup. For GitHub Actions:

hcl
timeout-minutes: 30

For AWS, enable DynamoDB TTL on the Expiration attribute to auto-clean stale locks after a reasonable window (e.g., 1 hour).

Resource already exists — import it instead of creating it

You write a Terraform resource block for an S3 bucket, run apply, and Terraform fails because the bucket already exists—perhaps it was created manually, by another tool, or in a previous incomplete migration.

Why it happens

Terraform assumes the resource doesn't exist until it creates it. If the remote resource was provisioned outside Terraform, the state file has no knowledge of it. When you apply, Terraform attempts to create it, the cloud provider rejects the request (bucket names are globally unique, for example), and your apply fails.

The correct approach: import

Instead of deleting the resource and recreating it, import it into your state. This tells Terraform "this remote resource now belongs to me."

Step 1: Add the resource block without any arguments (except the name):

hcl
resource "aws_s3_bucket" "legacy_data" {
  # Empty for now
}

Step 2: Run import:

hcl
terraform import aws_s3_bucket.legacy_data legacy-data-bucket-name

The format is terraform import <resource_type>.<local_name> <remote_id>. For S3, the remote ID is the bucket name. For EC2 instances, it's the instance ID. Consult the AWS provider docs for your resource type.

Step 3: Populate the resource block with configuration:

hcl
resource "aws_s3_bucket" "legacy_data" {
  bucket = "legacy-data-bucket-name"
}

resource "aws_s3_bucket_versioning" "legacy_data" {
  bucket = aws_s3_bucket.legacy_data.id
  versioning_configuration {
    status = "Enabled"
  }
}

Step 4: Run plan and verify. The plan should show no changes—if it does, your configuration doesn't match the actual remote state. Adjust your code to match reality.

Multi-resource imports

For complex resources (like RDS instances with multiple sub-resources), import may require several steps. Always import in dependency order: the primary resource first, then any child resources like security groups or parameter groups.

Cycle errors — break circular references with separate resources or data

Terraform throws a cycle error when it detects that Resource A depends on Resource B, which depends on Resource A. This deadlocks the dependency graph.

Why it happens

A common scenario: you create a security group and reference it in an auto-scaling group. The ASG has a tag that references the security group's name. The security group rule references the ASG's security group. Circular dependency.

Identifying the cycle

Run terraform graph to visualize your dependency graph, or simply read the error message—Terraform tells you the cycle path:

hcl
Error: Cycle: aws_autoscaling_group.app -> aws_security_group.app -> aws_autoscaling_group.app

Fix 1: Use data sources instead of resource references

If the cycle involves looking up an existing resource, use a data source instead:

hcl
# Before (cycle)
resource "aws_security_group" "app" {
  name = "app-sg"
}

resource "aws_security_group_rule" "app_ingress" {
  security_group_id = aws_security_group.app.id
  from_port         = 80
  to_port           = 80
  cidr_blocks       = [aws_security_group.app.id] # Circular!
}

# After (broken)
data "aws_security_group" "app" {
  name = "app-sg"
}

resource "aws_security_group_rule" "app_ingress" {
  security_group_id = data.aws_security_group.app.id
  from_port         = 80
  to_port           = 80
  cidr_blocks       = ["10.0.0.0/8"]
}

Fix 2: Separate resources into modules

If both resources are being created, split them into separate modules and pass outputs explicitly:

hcl
# main.tf
module "security" {
  source = "./modules/security"
}

module "compute" {
  source = "./modules/compute"
  security_group_id = module.security.app_sg_id
}

# modules/security/main.tf
resource "aws_security_group" "app" {
  name = "app-sg"
}

output "app_sg_id" {
  value = aws_security_group.app.id
}

# modules/compute/main.tf
variable "security_group_id" {
  type = string
}

resource "aws_autoscaling_group" "app" {
  vpc_security_group_ids = [var.security_group_id]
}

By splitting into modules, you force an acyclic dependency graph: security must complete before compute runs.

Fix 3: Remove the circular reference entirely

Often the cycle is a logic error. You're referencing something unnecessarily. Review the dependency chain and ask: "Do I really need this?" Frequently, the answer is no.

Inconsistent final plan — usually unknown values

A frustrating scenario: your terraform plan looks good, but terraform apply shows a different plan, or apply fails partway through with unexpected changes.

Why it happens

Some values are only known after a resource is created. For example, an RDS instance's endpoint is generated by AWS. If you reference that endpoint in another resource, Terraform can't plan the second resource until the first is applied. This causes a split-phase apply:

  • Phase 1: Create the RDS instance (plan looks fine)
  • Phase 2: Attempt to create dependent resources (plan shows additional changes)

In extreme cases, this causes the apply to fail partway through, leaving infrastructure in an inconsistent state.

The symptom

hcl
Terraform will perform the following actions:

  # aws_db_instance.main will be created
  + resource "aws_db_instance" "main" {
      + address                = (known after apply)
      + allocated_storage       = 20
      ...
    }

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

# Second plan
terraform plan
  # aws_route53_record.db_cname will be created
  + resource "aws_route53_record" "db_cname" {
      + name    = "db.example.com"
      + records = [aws_db_instance.main.address]
      ...
    }

Fix 1: Split into separate applies (temporary)

Apply the primary resource first, then apply dependents:

hcl
terraform apply -target=aws_db_instance.main
terraform apply

This is not a long-term solution, but it unblocks you immediately.

Fix 2: Use depends_on explicitly

Tell Terraform about the dependency so it plans both phases upfront:

hcl
resource "aws_route53_record" "db_cname" {
  name    = "db.example.com"
  records = [aws_db_instance.main.address]
  depends_on = [aws_db_instance.main]
}

This doesn't eliminate the unknown value, but it ensures Terraform understands the order and won't try to create the Route53 record until the RDS instance exists.

Fix 3: Pre-compute or generate IDs

For some resources, you can pre-generate the ID or name to break the dependency:

hcl
resource "aws_db_instance" "main" {
  identifier = "prod-database"
  allocated_storage = 20
}

resource "aws_route53_record" "db_cname" {
  name    = "db.example.com"
  records = ["prod-database.123456.us-east-1.rds.amazonaws.com"]
}

This is brittle (you're hardcoding what should be dynamic), so use only as a temporary workaround while you refactor.

Common mistakes

  • Force-unlocking without checking logs. A locked state usually means something is still running. Verify before forcing.
  • Mixing import with existing state. If you have partial state for a resource and import the same resource, you'll have duplicates. Clean up first.
  • Ignoring cycle warnings during code review. Cycles are easier to spot in pull requests before merging. Catch them early.
  • Using -target for permanent workflows. Targeted applies are debugging tools, not deployment strategies. They skip state validation and can leave resources orphaned.
  • Assuming unknown values will resolve. They won't. Use explicit dependencies or split your applies if you hit inconsistent plans repeatedly.

Key takeaways

  • State lock errors are usually safe to resolve by confirming the process is dead and waiting, not by force-unlocking reflexively.
  • Import existing resources instead of recreating them—it's the safe way to bring manual or external infrastructure under Terraform management.
  • Cycles signal a design issue. Break them with data sources, modularization, or by removing unnecessary dependencies.
  • Inconsistent plans with unknown values are a planning limitation, not a bug. Use explicit depends_on or split applies to work around them.
  • Always run plan before apply, and always review the diff—these four error classes are much easier to catch and handle in a safe environment.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles