Terraform Import at Scale: Bringing Legacy Infra Under Code
We inherited 200-odd AWS resources built by hand over four years, with no state file anywhere. Here's how import blocks and a generation workflow got them under Terraform without a rebuild.
Key takeaways
- We inherited 200-odd AWS resources built by hand over four years, with no state file anywhere.
- Here's how import blocks and a generation workflow got them under Terraform without a rebuild.
Terraform Import at Scale: Bringing Legacy Infra Under Code#
The handover doc for the account we inherited was one sentence: "It's all in AWS, ask Dmitri." Dmitri had left. There were roughly 200 resources, VPCs, security groups, RDS instances, a tangle of IAM roles, all clicked together in the console over four years. No Terraform, no CloudFormation, no state anywhere. The first time someone needed to change a security group rule, they did it in the console, because that was the only option, and that's exactly how the mess had grown in the first place.
Rebuilding from scratch was off the table. These were live production resources with data and traffic. We had to import what existed, in place, without recreating anything.
The old import command doesn't scale#
The classic terraform import command imports one resource into state but writes no configuration. You still have to hand-write the matching HCL, and if your HCL doesn't exactly match reality, the next plan wants to change or destroy the resource. For 200 resources, hand-writing HCL that matches four years of console tweaks is a week of soul-destroying work and a high chance of a plan that proposes to delete your production database.
Terraform 1.5+ import blocks changed this. You declare the import in config, and Terraform can generate the HCL for you:
import {
to = aws_db_instance.primary
id = "devopsness-prod-db"
}
import {
to = aws_security_group.api
id = "sg-0a1b2c3d4e5f6a7b8"
}
Then generate the configuration instead of writing it by hand:
terraform plan -generate-config-out=generated.tf
Terraform reads the live resource and writes HCL that matches its current state into generated.tf. That flips the work from "write perfect HCL from memory" to "review generated HCL and clean it up," which is an order of magnitude faster and far less error-prone.
Doing 200 resources without losing your mind#
You don't want 200 import blocks in one file and one giant plan. We batched by resource type and blast radius. Networking first (VPCs, subnets, route tables), because everything depends on it. Then security groups. Then the stateful stuff (RDS, ElastiCache) last, because those are the resources where a mistaken -replace is catastrophic.
To find what actually exists, we didn't trust the console. We queried the API and generated import blocks programmatically:
aws ec2 describe-security-groups \
--query 'SecurityGroups[].[GroupId,GroupName]' \
--output text | while read id name; do
printf 'import {\n to = aws_security_group.%s\n id = "%s"\n}\n' \
"$(echo "$name" | tr -c 'a-zA-Z0-9' '_')" "$id"
done > sg_imports.tf
That gave us a complete, accurate list, no relying on a stale runbook. For a first pass across everything, terraformer can bulk-generate both state and config, but its output is messy and we found it better for discovery than as a final artifact.
The generated config needs real cleanup#
-generate-config-out gives you working HCL, not good HCL. Two problems show up every time.
First, it hardcodes everything. IDs, ARNs, CIDR blocks are all literals. A generated security group references vpc-0abc123 as a string instead of aws_vpc.main.id. You have to go through and wire up the references so the resources actually depend on each other in the graph, otherwise Terraform doesn't know the SG belongs to the VPC and can't order operations correctly.
Second, it captures drift as if it were intent. If someone had manually set an odd apply_immediately or left a default tag off, the generated config bakes that in. We diffed the generated config against our conventions and normalized tags, naming, and lifecycle rules as we went.
The check that everything imported correctly is a clean plan:
terraform plan
# No changes. Your infrastructure matches the configuration.
That line, "No changes," is the whole goal. It means state, config, and reality agree. Until you see it, you're not done, and any resource still showing a diff is one that Terraform would alter or destroy on the next apply.
The trap that nearly bit us#
Import populates state but does not populate depends_on or data-source relationships you'd normally write by hand. An RDS instance imported cleanly, plan was green, but its subnet group had been imported separately and the implicit dependency wasn't expressed. A later refactor reordered things and Terraform tried to modify the subnet group in a way that would have forced the database to be recreated. We caught it in plan review, not apply, which is the entire argument for reading every plan line by line during a migration like this. terraform plan output during an import project is not a formality. It's the safety net.
The call we'd make#
Use import blocks with -generate-config-out, not the legacy terraform import command, for anything past a handful of resources. Generate the import blocks from the AWS API rather than a runbook so your list is real. Batch by dependency order, network first and stateful resources last, and treat the generated HCL as a draft to clean up, not a finished product. Above all, do not apply until terraform plan reports no changes, and read every plan during the migration like the database's life depends on it, because sometimes it does.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Distroless Docker Images: Smaller, Safer Production Containers
Our node image shipped 240 CVEs, most from OS packages we never called. Moving to distroless dropped the count to single digits and cut image size by 70%.
Agent Memory: Short-Term, Long-Term, and When You Need Neither
Most agents that "need memory" actually need a smaller context window and a database. Here's how we cut a support agent's token bill by 60 percent by deleting memory.
More from Infrastructure
Explore more articles in this category
Cloudflare's Clef: When a Decision Model Beats an LLM Call
Most moderation, routing, and triage calls aren't generation problems. Clef proves it by answering in 38.8 milliseconds instead of seconds.
Perplexity Left DynamoDB for CobbleDB: When Should You?
Perplexity built its own key-value store because DynamoDB's read path and bill stopped fitting 50 KB search items. Here is the checklist for when leaving is justified.
The Terraform Lock File Is Code: Review It Before You Init
A DPRK-linked group is mailing DevOps candidates Terraform take-home repos whose lock file points at a fake registry. terraform init then runs the attacker's provider.
You might have missed
Evergreen posts worth revisiting.