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:
aws dynamodb scan \
--table-name terraform-locks \
--region us-east-1Look 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:
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:
terraform plan -out=tfplanIf 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:
timeout-minutes: 30For 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):
resource "aws_s3_bucket" "legacy_data" {
# Empty for now
}Step 2: Run import:
terraform import aws_s3_bucket.legacy_data legacy-data-bucket-nameThe 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:
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:
Error: Cycle: aws_autoscaling_group.app -> aws_security_group.app -> aws_autoscaling_group.appFix 1: Use data sources instead of resource references
If the cycle involves looking up an existing resource, use a data source instead:
# 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:
# 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
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:
terraform apply -target=aws_db_instance.main
terraform applyThis 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:
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:
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_onor 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.






Comments
Loading comments…