Adopting Infrastructure You Didn't Build: terraform import Without the Tears
You inherited an AWS account with no state file. Here is the import workflow that avoids a 400-resource destroy plan and a rewrite nobody budgeted for.
The email is always the same: "We're consolidating accounts — can you Terraform the existing estate?" The estate has 400 resources, no state file, and a production load behind it. Importing that is the most common IaC job nobody writes tutorials about.
Why the obvious approach fails
The instinct is to write the configuration you wish existed, then run import until the plan goes green. What you actually get is a plan proposing to destroy 311 resources, because the config describes a security group with two rules and reality has nine, or the tags differ by one key.
Terraform compares config against state, and state against the world. Any mismatch between your description and reality becomes a diff, and a diff on a resource with ForceNew attributes becomes a replacement. On a database that is an outage.
So the job is not "write Terraform for this." The job is "reconcile description and reality, one resource at a time, without ever producing a destructive plan."
Import where the code lives
Terraform 1.5 and later let you declare imports in configuration instead of driving them from the CLI:
import {
to = aws_s3_bucket.logs
id = "acme-logs"
}
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs"
}
Run terraform plan, and the block is consumed: state gains the entry, the resource is now managed. The advantage over terraform import aws_s3_bucket.logs acme-logs is that imports live in version control. Review happens in a pull request, and the whole team can see what was adopted.
Both work. Declarative blocks are easier to re-run and easier to review.
Generate the config, then distrust it
Writing 400 resource blocks by hand is how the project dies in month two. Tools that read the live account and emit configuration — terraformer, cloud-specific exporters, or a script against the provider's API — get you 80% of the syntax in an afternoon.
They also confidently generate wrong code. Missing lifecycle rules, guessed variable names, hardcoded ARNs, subnets described with count where the real layout was a map. Treat generated output as a first draft from a colleague who has never met your team.
The workflow that holds:
- Generate config for one resource type.
- Import it, run
plan, and confirm the diff is empty. - Fix the config until an empty plan is genuinely empty — not
No changesafter you've deleted the resource from config. - Move to the next resource type.
An empty plan against imported resources is the only acceptance test that means anything here.
Slice by blast radius
Do not import the estate into one state file — state is the part you are really protecting, and one giant file puts all of it behind a single lock. Split along the same lines you'd use for anything else:
accounts/
├── network/ # VPC, subnets, route tables, NAT
├── data/ # RDS, ElastiCache, S3
├── compute/ # ASGs, launch templates, ALBs
└── dns/ # Route 53 zones and records
Network first — everything else references it through data sources. Data next, because it is the least replaceable. Compute last, since it is the most churn-tolerant.
Each slice is a separate state with its own lock, so an error while importing load balancers cannot touch the database.
When reality is wrong, not the config
Occasionally you discover a resource that should not exist — an orphaned volume, a security group nobody owns. Importing it means taking responsibility for it. That is a legitimate reason to leave it out of state and deal with it separately, as long as the decision is recorded rather than accidental.
Conversely, resources that are missing entirely (a bucket deleted in the console while still in someone's notes) are not an import problem. Recreate them deliberately.
Refactor once it's yours
Once everything imports cleanly, you can restructure. Renaming a resource is safe if you tell Terraform you meant it:
moved {
from = aws_s3_bucket.logs
to = aws_s3_bucket.log_archive
}
Without moved, the rename reads as "destroy the old bucket, create a new one." With it, state is rewritten and nothing is touched — the same one-state-per-blast-radius discipline applies once you are past adoption. Keep the block until everyone has run an apply, then delete it.
Summary
Adopting brownfield infrastructure is a reconciliation exercise, not a writing exercise: generate the config, import it, and iterate until plan returns nothing. Slice by blast radius, treat generated code as a draft, and use moved blocks when you refactor. The goal is not beautiful Terraform — it is an estate where the next change is a plan you can actually read.
SDP Clouds Team
DevOps and cloud engineers writing practical, battle-tested guides on CI/CD, Kubernetes, infrastructure as code, and production operations — every article is based on real incidents and real pipelines, not docs-page rewrites.
More about us →