DEV Community

Mayank Pratap Singh
Mayank Pratap Singh

Posted on

The Terraform Module Structure I Use for Every AWS Project

If you've worked on more than one AWS project with Terraform, you've probably hit this wall: what starts as a clean main.tf slowly turns into hundreds of lines of spaghetti — mixing networking, IAM, compute, and databases in one place.
I've seen this pattern repeated across multiple teams. The fix isn't working harder — it's structuring your modules the right way from day one.
In this article, I'll walk you through the exact Terraform module structure I use for every AWS project, why each decision was made, and the mistakes it prevents.

Why module structure matters more than you think
Terraform is infrastructure-as-code, which means all the same software engineering principles apply: separation of concerns, reusability, and avoiding duplication. A poor module structure leads to:

State file conflicts when multiple engineers work simultaneously
Inability to reuse code across environments (dev/staging/prod)
Blast radius issues — a change in one area accidentally destroying another
Slow terraform plan times because everything lives in one giant state

Real example: At a previous project, a single monolithic main.tf file had grown to 2,400 lines. A junior engineer changed a security group rule and accidentally modified an RDS parameter group in the same apply. Good module structure would have isolated these completely.

The folder structure
Here's the top-level layout I start every project with:

project-infra/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ └── prod/
├── modules/
│ ├── networking/
│ ├── eks/
│ ├── rds/
│ ├── iam/
│ └── s3/
└── global/
├── backend.tf
└── providers.tf

Each folder under modules/ is self-contained — it has its own main.tf, variables.tf, and outputs.tf. Nothing leaks between modules except through explicit outputs and inputs.

Breaking it down: the networking module
The networking module is always the foundation — every other module depends on it. Here's what mine looks like:

hclmodule "networking" {
source = "../../modules/networking"

vpc_cidr = var.vpc_cidr
public_subnet_cidrs = var.public_subnet_cidrs
private_subnet_cidrs = var.private_subnet_cidrs
availability_zones = var.availability_zones
environment = var.environment
}

Inside modules/networking/outputs.tf, I export the VPC ID, subnet IDs, and route table IDs. Every other module consumes these outputs — nobody hardcodes subnet IDs.

hcloutput "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}

output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}

Environment-specific configuration
Each environment folder (dev/, staging/, prod/) contains only the configuration that differs per environment. The actual infrastructure logic lives in the shared modules.

hcl# environments/prod/terraform.tfvars

environment = "prod"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
eks_node_count = 5
eks_instance_type = "m5.xlarge"
db_instance_class = "db.r5.large"

For dev, the same variables get smaller values — but the same module code runs. This is where you get real reusability.

Remote state and the backend
Each environment gets its own S3 backend with a DynamoDB lock table. This is non-negotiable on a team:

hclterraform {
backend "s3" {
bucket = "mycompany-tf-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-lock"
encrypt = true
}
}

Tip: Use a separate AWS account (or at minimum a separate S3 bucket with strict IAM policies) for storing state files. State files contain sensitive data including database passwords and resource IDs.

Three rules I never break

  1. Modules never call other modules directly. Only the environment-level main.tf calls modules. This prevents circular dependencies and keeps the dependency graph flat and readable.
  2. No hardcoded values inside modules. Every value that could differ between environments must be a variable with a description and type constraint. If I'm tempted to hardcode something, that's a signal it belongs in tfvars.
  3. Every output has a description. Outputs without descriptions are useless to future you and your teammates. A one-line description of what the output is and when to use it saves hours of confusion.

Putting it together
This structure has served me across projects ranging from simple 3-tier web apps to multi-account EKS platforms. The upfront investment in structure pays back within weeks — especially when onboarding new engineers or debugging a production incident at 2am.
Start with this layout even for small projects. It costs almost nothing extra and eliminates an entire class of infrastructure bugs before they happen.

In the next article, I'll go deeper into the EKS module specifically — how I configure node groups, IRSA roles, and the Helm chart integration that makes it production-ready from day one.

Top comments (0)