Two Terraform Traps That Burned Me: Hidden Defaults & Circular Dependencies
Jenkins
Source: Dev.to | Jan 22, 2026 (UTC)
Mondays are for planning. Fridays are for “quick” tasks that turn into nightmares.
Recently, I was tasked with a classic brownfield migration: taking existing AWS infrastructure (ClickOps’d over years) and bringing it under Terraform management.
I thought writing a script to wrap terraform import would be enough. I was wrong. I ran into two distinct classes of problems that standard import tools often miss: Hidden API Defaults and Graph Cycles.
Here is a technical breakdown of what went wrong and how we solved it using graph theory and strict schema mapping.
Trap 1: The root_block_device & The “Silent” Replacement
The task was simple: Import 34 production EC2 instances. After generating the HCL code and running terraform plan, I expected a clean state.
Instead, I got this:
Plan: 34 to add, 0 to change, 34 to destroy. # aws_instance.prod_web_01 must be replaced
Enter fullscreen mode
Exit fullscreen mode
Every single production instance was flagged for replacement.
The Investigation
Rule #1 of IaC is “Always read the plan.” But when the plan says “replace,” you need to know why.
The diff looked like this:
-/+ resource "aws_instance" "prod_web_01" { ~ id = "i-0abc123def456" -> (known after apply) - root_block_device { - volume_size = 100 # Actual Prod State - volume_type = "gp3" - device_name = "/dev/xvda" } + root_block_device { + volume_size = 8 # AMI Default + volume_type = "gp2" } }
Enter fullscreen mode
Exit fullscreen mode
The Root Cause: Read-Only vs. Writable Attributes
The issue wasn’t just “I forgot to declare values.” It was a conflict between the AWS API and the Terraform Schema.
AWS API Reality: When you query an instance, AWS returns everything, including the DeviceName (e.g., /dev/xvda).
Terraform Schema: The device_name inside root_block_device is a Computed (Read-Only) attribute. You cannot set it.
If you blindly map the API response to HCL, Terraform errors out because you’re trying to set a read-only field. If you omit the block entirely (thinking “it already exists”), Terraform assumes you want the AMI defaults (often 8GB gp2).
Because AWS cannot shrink a 100GB volume to 8GB in-place, Terraform’s only option is to destroy and recreate the instance.
The Fix: Surgical Mapping
You can’t just dump the API response. You have to filter it through a logic layer that understands the Terraform provider’s quirks:
# Pseudo-code for the fix def transform_root_block_device(api_response): ebs = api_response.get('Ebs', {}) volume_type = ebs.get('VolumeType', 'gp2') result = { # Keep writable attributes 'volume_size': ebs.get('VolumeSize'), 'volume_type': volume_type, 'delete_on_termination': ebs.get('DeleteOnTermination'), 'encrypted': ebs.get('Encrypted'), } # Filter out Read-Only attributes that cause errors # - device_name # - volume_id return result
Enter fullscreen mode
Exit fullscreen mode
This ensures the generated code matches the actual state of the disk without triggering schema violations.
Trap 2: The Cycle (Graph Theory vs. AWS Reality)
If the first trap was a configuration error, the second was a fundamental structural conflict.
Terraform requires a Directed Acyclic Graph (DAG). AWS allows cycles.
The Deadlock
The most common culprit is Security Groups. Imagine two microservices:
SG-App allows outbound traffic to SG-DB
SG-DB allows inbound traffic from SG-App
If you write this with inline rules (which is what terraform import generates by default), you create a cycle:
When building RepliMap (the tool I wrote to automate this), I realized we couldn’t just export resources one by one. We had to model the entire AWS account as a graph using NetworkX.
We use Tarjan’s algorithm to detect Strongly Connected Components (SCCs)—the “knots” in the graph.
Once a cycle is detected, we use a “Shell & Fill” strategy to break it:
Create Empty Shells: Generate the Security Groups with no rules. Terraform can create these instantly because they have no dependencies.
Fill with Rules: Extract the rules into separate aws_security_group_rule resources. These reference the IDs of the shells created in step 1.
Tools like terraform import or Terraformer are great starting points, but they often act as simple API-to-HCL dumpers. They don’t always account for:
Implicit Defaults: Where missing config != existing state.
Graph Topology: Where valid AWS states are invalid Terraform states.
For small projects, you can fix these manually. For brownfield migrations with 2,000+ resources, you need a deterministic engine to handle the translation.
I’ve open-sourced the documentation and the read-only IAM policies for the engine we built to solve this. If you’re interested in the edge cases of AWS imports, check it out:
AWS Infrastructure Intelligence Engine
Reverse-engineer any AWS account. Visualize dependencies. Generate Terraform. Optimize costs
👋 About This Repository RepliMap is a commercial tool built with a “Local-First” architecture. This repository (replimap-community) hosts documentation, issue tracking, and examples. The core engine is distributed via PyPI. Your AWS credentials and data never leave your machine — the only network call is license key validation.
You inherited an AWS account. Or maybe you built it yourself over 3 years of “just one more click.”
Now you have:
🤷 500+ resources and no idea what connects to what
😰 No Terraform — everything was ClickOps
💸 Oversized instances burning money 24/7
📋 SOC2 audit next month — good luck
Sound familiar?
RepliMap scans your AWS, builds a dependency graph, and gives you…
Залишити відповідь