SDP Clouds
← All posts
IaC·4 min read

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:

hcl
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:

  1. Generate config for one resource type.
  2. Import it, run plan, and confirm the diff is empty.
  3. Fix the config until an empty plan is genuinely empty — not No changes after you've deleted the resource from config.
  4. 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:

text
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:

hcl
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.

#terraform#iac#import#brownfield

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 →

Related articles