Article

Importing the Mess: How to Get a Hand-Built AWS Estate Into Terraform Without Breaking It

by Gary Worthington, More Than Monkeys

At some point in your career you will inherit an AWS account that was built entirely by hand, over years, by people who have since left. There’ll be a VPC nobody can explain, security groups with rules named “temp — remove after migration” dated 2021, a Lambda that runs every night and does something to a bucket, and one EC2 instance called test-final-2-USE-THIS-ONE that turns out to process all of the company's invoices.

There is no documentation. The documentation is the account.

Everything in this series so far has quietly assumed a greenfield: part one built from nothing, part two arranged environments, part three shaped the modules, part four put a pipeline around the lot. Lovely. But most real Terraform adoption doesn’t start from nothing — it starts from the account described above, running production, earning the money that pays for the migration. This article is about getting that estate into Terraform without an outage: what to import, what to leave, and the loop that makes the whole thing safe.

A quick recap

The one piece of theory you need from part one: Terraform only manages what’s in its state file. Resources it didn’t create are invisible to it — it won’t change them, won’t protect them, won’t delete them. Importing is the act of introducing an existing resource to Terraform’s state, so that from that moment on, Terraform believes it owns it.

Note what importing is not. It’s not a change to your infrastructure. A correctly executed import touches state and nothing else — the bucket doesn’t move, the instance doesn’t blink. All of the risk lives in what happens next, which is why the discipline below is entirely about what happens next.

Stop digging first

Before importing anything, make one rule and enforce it from today: everything new is Terraform.

New service? Terraform. New bucket? Terraform. “Just a quick security group for testing”? Terraform, and it can be quick there too. The legacy estate is a fixed-size problem the moment you stop adding to it, and a growing one until then. Teams that skip this step spend eighteen months importing yesterday’s clicking while today’s clicking accumulates behind them, like painting the Forth Bridge, if the bridge were also being extended.

The rule: importing is bailing water; this is fixing the leak. Fix the leak first.

You do not have to import everything

The instinct is to make Terraform own the whole account, and the instinct is wrong. Import is a means — the end is that the infrastructure you actively change is reviewable, reproducible, and safe to operate. Not everything qualifies.

Triage the estate into three piles. First, things that change: services you deploy, networking you evolve, anything touched monthly. These get imported, and they’re the whole point. Second, things that are dying anyway: the old reporting stack scheduled for decommission doesn’t need importing, it needs a funeral — importing it is embalming. Third, things that sit still: the static one-off created in 2019 that hums along untouched. Leave it. You can import it the first time you actually need to change it, which may be never.

This pile-sorting is also, quietly, the first real audit the account has ever had. You’ll find resources nobody recognises, spend that nobody’s questioned, and at least one thing switched off since March that nobody’s noticed. That knowledge is worth as much as the Terraform.

The loop

Every import, from a single bucket to a whole service, follows the same four-step loop. Burn it in, because the safety of the entire migration is this loop and nothing else.

Write configuration that describes the resource as it exists — not as you wish it were. Add an import block telling Terraform which real resource it maps to. Run plan, and read what it says. Repeat until the plan says No changes.

Since Terraform 1.5 the import itself is declarative — a block in your code, reviewed in a PR, applied by your pipeline like anything else:

import {
to = aws_s3_bucket.invoices
id = "acme-invoices-prod"
}

resource "aws_s3_bucket" "invoices" {
bucket = "acme-invoices-prod"
tags = {
ManagedBy = "terraform"
}
}

Plan shows 1 to import, apply records the mapping in state, and — this being the part that matters — the next plan is the referee's verdict. No changes means your code and reality agree, and the resource is safely under management. Anything else means they disagree, and you stop.

That’s the golden rule of the whole exercise: No changes is the finish line for every import, and nothing gets left mid-loop overnight. An import whose plan still shows diffs is a trap armed and waiting for whoever runs apply next.

Reading the diffs like a bomb disposal officer

The diffs are where imports go wrong, so let’s be precise about what you’re looking at when a freshly imported resource plans a change.

Your code said the bucket has three tags; reality has five, including one a compliance tool added in 2023. Your code says the instance is a t3.large; reality says someone bumped it to xlarge during an incident and never mentioned it. Every one of these diffs is a discrepancy between your description and the truth, and each demands a decision: adopt reality into the code (almost always the right answer during import), or deliberately change reality to match the code (occasionally right, only ever on purpose).

What you must never do is shrug and apply. During an import, an unexpected diff is a question, not a tidy-up. I watched a team import a security group, get a plan that wanted to remove four rules with names like temp-allow-vendor, and approve it on the reasonable-sounding grounds that temporary rules from 2021 were overdue for removal. One of those rules was how the payment provider reached the webhook endpoint. The resulting outage was brief, educational, and entirely self-inflicted — the plan told them exactly what it was going to do, and they read it as housekeeping instead of as a warning.

The rule: during import, reality is innocent until proven guilty. The clicked-together estate is working; your code is the new arrival that has to prove it describes the working thing correctly.

And for anything stateful — databases above all — put a prevent_destroy lifecycle rule on before you import, so that even a badly wrong plan can't turn into a deleted RDS instance. During an import phase, any plan containing a destroy is wrong until proven otherwise, twice, by two people.

Let Terraform write the first draft

Writing config for a resource with forty attributes is tedious, so Terraform will draft it for you. Put the import blocks in an otherwise empty directory and run:

terraform plan -generate-config-out=generated.tf

Terraform inspects the live resources and writes HCL matching what it finds. Two honest caveats. The flag is still officially experimental — fine for drafting, but treat the output as a suggestion, not gospel. And the generated code is a transcript, not a design: every attribute pinned explicitly, defaults spelled out, IDs hard-coded where references should be, no variables, no modules, no taste.

Use it the way you’d use any first draft — as raw material. Strip the attributes that are just provider defaults, replace hard-coded IDs with references, fold the survivors into the module structure from part three. The failure mode is teams committing generated.tf wholesale, at which point they've achieved a codebase precisely as readable as the console they were escaping, only now in files.

If you’re importing a fleet of similar things — thirty buckets, say — Terraform 1.7+ lets import blocks take for_each, which turns thirty copy-pasted blocks into one. Draft with generation, refine by hand, prove with plan.

An order of attack that works

Where to start matters more than people expect, because the first import sets the team’s nerve.

Start with something real but low-stakes — a small internal service, a handful of buckets and their policies. You want the first few loops to build the habit (write, import, plan, No changes) somewhere a mistake is an inconvenience rather than an incident.

Then work up through the things you change most often, because that’s where the payoff lives: every imported service is one more thing that now goes through review, pipeline, and plan instead of through the console. Import a service at a time, into the per-service state files part two argued for — an import mistake in the orders service state can’t touch payments if they were never in the same state to begin with.

Do the networking last. The VPC, the subnets, the route tables — everything depends on them, which makes them the highest-consequence imports in the estate, and by the time you get there you’ll have run the loop fifty times and your code will be calling a networking module you actually trust. The riskiest import deserves your most practised hands.

And resist the ticking clock. An import migration running alongside normal delivery takes months, and that’s the correct speed. The estate took five years of clicking to build; it does not need to be rescued in a fortnight, and every shortcut you take under self-imposed deadline pressure gets paid back with interest by whoever’s on call.

When you get one wrong

You will, eventually, import something into the wrong address, or import something you shouldn’t have taken ownership of at all. The escape hatch matters: since Terraform 1.7, a removed block does the reverse of an import — it takes the resource out of state without destroying it, declaratively, through the same review-and-pipeline route as everything else:

removed {
from = aws_s3_bucket.oops
lifecycle {
destroy = false
}
}

The resource returns to being unmanaged, unharmed, and you try again properly. Knowing this exists — and that destroy = false line is the entire point of it — is the difference between a calm correction and a panicked one.

The point of all this

The estate you inherited isn’t really a pile of resources. It’s a pile of decisions — five years of them, made under pressure, by people who knew things you don’t, none of it written down. Importing is the act of recovering those decisions one plan at a time: every diff you investigate is a question answered, every No changes is a piece of the system that's finally documented, reviewed, and safe to hand to the next person.

That’s the actual deliverable. Not state file entries — recovered knowledge, in the only documentation format that can’t drift from the truth, because part four’s nightly plan checks it every morning.

The account stops being an archaeology site and starts being a codebase.

And nobody ever again has to discover, at 2am, what test-final-2-USE-THIS-ONE does.

Gary Worthington is a software engineer, delivery consultant, and fractional CTO who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.

Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance.