Back to Blog

The Terraform Patterns That Actually Scaled My AWS Infrastructure

Most Terraform repos start clean and end up as a graveyard of copy-pasted

Wajahat Zia
Wajahat Zia
4/9/2026
0 views

Most Terraform repos start clean and end up as a graveyard of copy-pasted .tf files. You add one more environment and suddenly you're maintaining four copies of the same EC2 config, hoping nobody forgot to update the third one. I spent months refactoring my way out of that mess — here are the four patterns that actually survived contact with production.

The Copy-Paste Trap

Here's how Terraform projects usually grow: you start with a dev/ folder. It works. So you copy it to staging/, change a few values. Copy it again to prod/, change a few more. Six months later you have four environments, each with subtle differences nobody can explain, and a single state file that makes every apply feel like defusing a bomb.

I've lived this. Adding a new environment meant duplicating hundreds of lines and manually hunting for values to change. Miss one security group reference? Silent production divergence. Need to test a change? Every terraform plan ran against a real AWS account, so engineers avoided making changes they should have been making. The only person who could deploy was the one who'd memorized the right -chdir paths. That's not infrastructure as code — that's tribal knowledge with a .tf extension.

Your Terraform Repo Is a Product

That realization changed how I approached the problem. Your Terraform repo is a product. Design it like one — with a schema, a data layer, an API, and a dev environment.

The patterns that scale aren't about writing clever HCL. They're about treating your infrastructure codebase with the same design rigor you'd apply to an application. Here are the four that made the biggest difference.

Modules as Schema, Variables as Data

The worst pattern in Terraform is one .tf file per resource. Adding a new EC2 instance means copying 40 lines, changing the name, AMI, instance type, subnet, and security groups. Multiply by four environments. Forget one field and enjoy your next production incident.

The fix: a single module that accepts a map. Each entry in the map is one resource. The module enforces what fields exist (the schema), while .tfvars holds the values (the data):

hcl
module "ec2" {
source = "../../../../modules/ec2"
instances = {
"api-server" = { ami_id = "ami-0abc111", instance_type = "t3.medium", ... }
"worker-node" = { ami_id = "ami-0def222", instance_type = "t3.large", ... }
}
}

Inside the module, an orchestrator fans out to a components/instance sub-module via for_each. The orchestrator handles iteration. The component handles a single resource. Add an instance? Add a map entry. Remove one? Delete the entry. The module guarantees that every instance gets the same tags, monitoring config, and lifecycle rules — you can't accidentally forget a field because the variable definition won't let you.

I built this two-tier pattern for every resource type: EC2, S3 (with sub-components for encryption, versioning, CORS, and policies), security groups, IAM roles, and Secrets Manager. The module directory always looks the same:

plain text
modules/ec2/
main.tf # Orchestrator — iterates with for_each
variables.tf
outputs.tf
components/
instance/ # Single resource definition
main.tf
variables.tf
outputs.tf

When infrastructure becomes data, adding a new environment stops being an engineering project and starts being a data-entry task. That's the shift you want.

Layered State with Blast Radius Control

One state file for everything means one bad apply can cascade anywhere. A typo in a staging EC2 tag shouldn't be able to destroy your VPC, but with a monolithic state file, Terraform doesn't know the difference.

I split the infrastructure into three layers, each with its own state file and independent apply cycle:

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

Layer 3 reads Layer 2's outputs via terraform_remote_state. The deployment order is always global first, shared second, environments last. Each layer is independently plan-able and apply-able:

hcl
data "terraform_remote_state" "shared" {
backend = "s3"
config = {
bucket = "123456789012-terraform-state"
key = "us-east-1-shared/infra.tfstate"
region = "us-east-1"
}
}
# Then reference shared outputs:
# local.vpc_id = data.terraform_remote_state.shared.outputs.vpc_id

The key insight: blast radius is a design decision, not an accident. Separating state by dependency layer means a staging deploy can never touch your VPC or IAM roles. It also means you can give junior engineers access to environment layers without risking shared infrastructure. When someone asks "is it safe to apply this?" the answer is always yes — because the architecture made it safe.

I didn't land on this structure immediately. I renamed the directory layout three times before settling on the org/region/scope/env hierarchy. The naming doesn't matter as much as the principle: group by blast radius, not by convenience.

The Makefile as Your Operations API

Here's a question: how does someone new to your team deploy the staging environment? If the answer is "read the README and run a terraform command with the right -chdir path," you've already lost. They'll get the path wrong, use the wrong region, or apply environments out of order.

I wrapped every Terraform operation in a Makefile with systematically named targets:

makefile
# Naming: {action}-{layer}-{region}[-local]
plan-shared-us-east-1:
terraform -chdir=$(SHARED_US_EAST_1_DIR) plan
plan-shared-us-east-1-local:
tflocal -chdir=$(SHARED_US_EAST_1_DIR) plan

Every real AWS target has a -local counterpart that runs against LocalStack instead. make help prints the initialization order so nobody has to guess what depends on what.

This might seem like a minor quality-of-life improvement, but it changed how my team interacted with the infrastructure. When operations are discoverable — when you can type make and tab-complete your way to the right command — engineers actually make changes instead of avoiding them. The Makefile is the API contract for your infrastructure. It encodes deployment order, eliminates path typos, and makes switching between local and cloud a one-word suffix.

Some teams use Terragrunt or custom wrapper scripts for this. The tool doesn't matter. What matters is that your operations have a discoverable, consistent interface that someone can use on day one.

LocalStack as Your Local Dev Environment

The biggest blocker to infrastructure-as-code adoption isn't learning HCL. It's fear. Every terraform apply against a real AWS account carries real risk and real cost. So engineers batch up changes, deploy infrequently, and when they do deploy, the diff is massive and terrifying.

tflocal broke that cycle for me. It's a drop-in wrapper around terraform that redirects all AWS API calls to a local LocalStack instance. Same Terraform code, zero AWS cost, zero risk of breaking anything real. A single boolean handles the few differences between LocalStack and real AWS:

hcl
ami_id = var.is_localstack ? "ami-00000000" : "ami-0abc12345"
monitoring = var.is_localstack ? false : true

Before touching real AWS, I run the full init / plan / apply cycle locally. If it works on LocalStack, I have confidence it'll work in the cloud. Not perfect confidence — LocalStack doesn't replicate every AWS behavior — but enough to catch the mistakes that would otherwise require a rollback in production.

LocalStack does for Terraform what Docker did for application development. It gives you a local environment where you can break things freely and iterate fast. Once you have that, engineers stop treating infrastructure changes like surgery and start treating them like normal code changes. The deployment frequency goes up, the batch size goes down, and the risk per deploy drops dramatically.

The Honest Caveats

Map-driven modules add indirection. Debugging a single resource means tracing through the orchestrator, the component, and the variable map. For teams new to Terraform, this feels over-engineered. My advice: start with flat resources and extract the pattern once you have three or more instances of the same type. Don't pre-optimize.

Three layers might be too many or too few. For a small project with one region and two environments, the overhead isn't worth it. For very large ones, you might need a fourth layer — separating networking from other shared resources, for example. Match the number of layers to your blast radius tolerance.

terraform_remote_state creates implicit coupling. If you rename an output in the shared layer, every environment that references it breaks. Document your cross-layer contracts and treat output changes like API versioning — because that's exactly what they are.

LocalStack covers the 80%. Advanced IAM policy evaluation, some S3 edge cases, and newer AWS services may behave differently or not be available in the free tier. Use it for the rapid iteration loop and validate the final result against a real dev account.

The Takeaway

Treat your Terraform repo like a product: schema (modules), data (variables), API (Makefile), dev environment (LocalStack). When you apply application design principles to infrastructure code, your repo stops being a pile of HCL files and starts being a tool your team actually wants to use.

This is the companion to How I Migrated My Entire AWS Infrastructure to Terraform — that article covers the migration journey, this one covers the patterns that kept me sane after. Have patterns that work for your team? I'd love to hear them

TerraformInfrastructure-as-CodeAWSDevops