Back to Blog

How I Migrated My Entire AWS Infrastructure to Terraform (And You Should Too)

How I migrated a production AWS environment to Terraform — from audit to import blocks, reusable modules, LocalStack testing, and phased rollout.

Wajahat Zia
Wajahat Zia
4/8/2026
0 views

You have a production AWS environment that works. EC2 instances running, security groups configured, secrets stored, S3 buckets serving files. Now ask yourself: could you recreate it from scratch if you had to?

If the answer involves clicking through the AWS console for three days straight, you have a problem. I had the same one — and I decided to fix it.

The ClickOps Trap

Here's how most AWS environments end up: someone provisions a VPC through the console. Someone else adds an EC2 instance. A security group rule gets added during a 2am incident. A secret gets created in one environment but not another. Over months, you end up with infrastructure that works but that nobody fully understands.

I was living this reality. Our platform ran across two AWS regions with multiple environments (dev, staging, production). When I tried to map out what we actually had, the list kept growing: VPCs, multiple subnets per region, a dozen-plus security groups, multiple EC2 instances per environment, a handful of S3 buckets, IAM roles and policies, Secrets Manager entries with hundreds of config keys, SNS topics, SES configurations, and managed database clusters.

The worst part? Spinning up a new environment meant someone had to click through dozens of console screens, guessing at configurations based on the existing environments. Knowledge lived in people's heads. When someone left, that knowledge walked out the door with them.

I needed to get all of this into code. But I wasn't going to rewrite everything from scratch — I needed to capture what existed first, then improve it.

Import First, Modularize Second, Migrate in Layers

That became my guiding principle. The secret to a successful brownfield Terraform migration isn't writing perfect IaC from day one. It's getting your existing resources under Terraform's control as fast as possible, then refactoring the code while the infrastructure stays safe.

Here's what that looked like in practice.

The Audit

You can't migrate what you can't see. Before I wrote a single line of HCL, I needed a complete inventory of every AWS resource we were running.

I started with the AWS console and CLI, region by region, service by service. Then I used terraformer to pull all my existing AWS resources into Terraform code — complete with import blocks for each resource. The generated code was messy — verbose, flat, and full of hardcoded values — but it gave me two things I couldn't easily build by hand: a full inventory of what existed, and a working set of import blocks that mapped every resource to Terraform state.

The surprise was how much more existed than anyone thought. Security groups with rules nobody remembered adding. S3 buckets created for one-off experiments that were still running. IAM policies attached to roles that hadn't been used in months.

If you skip this step and start importing blindly, you'll miss resources. And a half-migrated infrastructure is worse than a fully manual one — you get the false confidence of IaC without the actual coverage.

Declarative Imports Over CLI Commands

The old way of importing resources into Terraform was painful. You'd run terraform import aws_instance.web i-0abc123 from the command line, manually write the matching .tf configuration, run terraform plan, see a massive diff, tweak the config, plan again, repeat until the diff was clean. One resource at a time. For dozens of resources across multiple services, that's not realistic.

This is where terraformer earned its keep. It generated import blocks for each resource it discovered — VPCs, subnets, EC2 instances, security groups, S3 buckets, the lot. Instead of hand-writing each one, I had a starting point for every module:

hcl
import {
to = module.ec2.module.instances["app-staging"].aws_instance.this
id = "i-0a1b2c3d4e5f67890"
}

These blocks are declarative. They live in your code, get version-controlled, and are reviewable in PRs. Combine them with terraform plan -generate-config-out=generated.tf and you get auto-generated starter configuration that you can clean up and refine.

The key difference: import blocks make the process repeatable and auditable. They don't live in someone's shell history — they live in your codebase.

Designing Modules for Migration and Scale

Once resources were imported, two challenges hit at the same time: existing resource names didn't match my module conventions, and I had dozens of nearly-identical resources that needed a scalable pattern.

For the naming mismatch, I used a simple boolean toggle. Our existing resources had names like prod-webserver and staging-api, but my modules wanted to prefix everything with ${account_id}-${env}-. During import, the names need to match exactly — otherwise Terraform can't find them.

hcl
global_resource_prefix = var.is_import ? "" : "${local.aws_account_id}-${local.environment}-"

Set is_import = true during import — prefixes are disabled, names match, import succeeds. After terraform plan shows no changes, flip it to false. Going forward, new resources get the standardized naming convention. I used the same pattern for Secrets Manager, skipping version creation during import since the secret already has a value.

For the scalability problem, I landed on map-driven modules with for_each:

hcl
module "ec2" {
source = "../../../../modules/ec2"
instances = {
"app-staging" = { ami_id = "ami-0abc111", instance_type = "t2.small", ... }
"app-prod" = { ami_id = "ami-0def222", instance_type = "t2.medium", ... }
}
}

Inside the module, a components/instance sub-module handles the actual aws_instance resource. The top-level module iterates over the map. Want to add a new instance? Add a map entry. Want to remove one? Delete the entry. I built this pattern for every resource type: EC2, S3, security groups, IAM roles, secrets, SNS topics.

The real win here isn't "infrastructure as code." It's infrastructure as data. Your modules are the schema, your .tfvars are the data. Adding a new environment becomes a data entry task, not an engineering project.

LocalStack for Fearless Testing

The biggest blocker to IaC adoption isn't writing the code — it's fear of breaking production. Every terraform apply against a real AWS account carries risk.

LocalStack + tflocal eliminated that fear. tflocal is a drop-in wrapper around terraform that redirects all AWS API calls to a local LocalStack instance. Same code, zero cloud costs, no risk. I added an is_localstack variable to handle the few differences (AMI IDs, instance types), and a Makefile with dual targets for every environment — one for real AWS, one for LocalStack.

Before touching real AWS, I'd run the full init, plan, apply cycle locally. If it worked on LocalStack, I had confidence it would work in the cloud. Not perfect confidence — LocalStack doesn't cover every AWS service — but enough to catch the obvious mistakes before they hit production.

Migrating in Layers

This was the most important architectural decision. Instead of one massive Terraform state file managing everything, I split the infrastructure into three layers:

plain text
envs/
myorg/
global/ # Layer 1: IAM roles, policies (region-independent)
us-east-1/
shared/ # Layer 2: VPC, subnets, SGs, S3 (per-region)
staging/ # Layer 3: EC2 instances, secrets (per-env)
prod/ # Layer 3: EC2 instances, secrets (per-env)
eu-west-1/
shared/ # Layer 2
staging/ # Layer 3

Each layer has its own state file, its own terraform apply cycle, and its own blast radius. Layer 3 references Layer 2 outputs via terraform_remote_state:

hcl
data "terraform_remote_state" "shared" {
backend = "s3"
config = {
bucket = "123456789012-terraform-state"
key = "us-east-1-shared/infra.tfstate"
}
}

The deployment order is always: global first, shared second, environments last. A bad change to a staging EC2 instance can never accidentally affect your VPC or IAM roles. Blast radius is contained by design.

I didn't land on this structure immediately — I renamed the directories three times before settling on the org/region/scope/env layout. Don't expect to get the organization right on the first try.

The Honest Caveats

This isn't a weekend project. My migration took weeks of incremental work. Expect terraform plan to surprise you constantly — cloud defaults you never set, tags you forgot, attributes Terraform tracks that you didn't know existed.

Terraformer is a starting point, not a destination. It generated the import blocks and initial .tf code that got me off the ground, but the generated modules needed significant cleanup. I rewrote them from scratch into the map-driven pattern. Terraformer gave me the raw material; the modules I built were the finished product.

lifecycle { ignore_changes } will save you. Especially ignore_changes = [user_data, user_data_base64] on EC2 instances. Without it, Terraform sees a diff on every plan and wants to recreate your production instance. Ignore it during migration; manage it later.

LocalStack covers the 80%. Some services — advanced IAM policy evaluation, complex SES configurations — behave differently or aren't available in the free tier. Use LocalStack for rapid iteration, but validate the final result against a real dev account.

Trust but verify. After every layer's apply, I checked the AWS console to confirm nothing actually changed in production. Terraform said no changes? I verified. Paranoid? Maybe. But I sleep well.

The Takeaway

Import first, modularize second, migrate in layers. Don't wait for perfect Terraform code — get your infrastructure under state management now, then iterate. The gap between "we should really Terraform this" and "we actually have IaC" is one import block at a time.

Your future self — the one who needs to spin up a new environment in 20 minutes instead of 3 days, or recreate production after a disaster, or onboard a new team member without a week of tribal knowledge transfer — will thank you.

Have you migrated brownfield infrastructure to Terraform? I'd love to hear what patterns worked for you — or what went hilariously wrong. Drop a comment

TerraformAWSInfrastructure-as-CodeDevops