ADR 001: Terraform Module Structure
DevOps Studio › Docs › Architecture Decisions › ADR 001
Status: Accepted
Context
Lab 01 has to work for three different customer situations without becoming three different codebases:
- A greenfield account that needs a full three-tier network built from nothing.
- A team that already has a VPC and only wants the compute and database layers attached to it.
- Three environments (dev, staging, prod) that share the same design but need different sizing, HA, and cost profiles.
A single flat main.tf with every resource inline would work for a demo, but it fails the moment a customer wants to reuse the networking layer for a different workload, or asks "what changes between dev and prod?" and the answer is scattered across hundreds of resource blocks.
Decision
Split the lab into three independent modules, composed from a thin root module, with per-environment .tfvars files driving sizing:
labs/01-terraform-foundations/
├── main.tf # Composes the three modules
├── environments/
│ ├── dev.tfvars
│ ├── staging.tfvars
│ └── prod.tfvars
└── modules/
├── vpc/ # Networking — owns subnets, routing, NAT, flow logs
├── web-app/ # Compute — ASG, ALB, launch template
└── database/ # Data — RDS, subnet group, parameter groupEach module is scoped to a single infrastructure layer and communicates with the others only through explicit outputs and variables — the web-app module takes subnet IDs and a security group as inputs, it never reaches into the VPC module's resources directly.
The VPC module also supports a bring-your-own-VPC mode: when a customer already has network infrastructure, vpc_id and subnet IDs can be passed in instead of created, and the module skips VPC creation while still returning the outputs the other modules expect. This is the same pattern used in Lab 02's EKS module, so a customer's existing VPC can be shared across both labs.
Environment differences (instance sizing, Multi-AZ RDS, NAT gateway count, backup retention) live entirely in environments/*.tfvars — the module code never branches on environment name.
Consequences
Positive:
- Each module can be validated (
terraform validate,terraform plan) and reasoned about independently. - The VPC module is reusable as-is by Lab 02 and Lab 08, avoiding three copies of the same networking logic.
- Promoting a change from dev → staging → prod is a
.tfvarsdiff, not a code diff — reviewable in a PR without reading Terraform. - New environments (e.g. a second region) are additive: a new
.tfvarsfile, not a new module.
Negative / tradeoffs:
- The bring-your-own-VPC path (
count = local.create_vpc ? 1 : 0throughout) adds conditional-resource complexity to the VPC and EKS modules. It reads harder than a module that only ever creates its own VPC. - Three environments means three sets of real AWS costs if all are left running — the Cost Management guide and
make destroyexist specifically to keep that from becoming a surprise bill. - Module boundaries are drawn along infrastructure layers (network / compute / data), not along team ownership. A customer whose networking and platform teams are organizationally separate may want the VPC module split into its own repository/state file rather than composed in the same root module.