Terraform Module Layout I Use for Multi-Environment AWS
- Shameem Abdul Salam
- Terraform , Aws
- June 16, 2026
Table of Contents
This post is for teams already running Terraform in AWS who have felt the pain of three “similar but not quite the same” environment folders. It is not a Terraform 101 tutorial, and it assumes you know what a module, variable, and remote state backend are.
If you have ever terraform apply’d to staging with a hard-coded production VPC ID, most of this will feel familiar. The goal is a layout you can hand to a teammate before the next region or environment fork.
Why a shared module layout matters
Drift between environments is a production incident waiting to happen. The symptoms look innocent:
- Staging has a newer security group rule production never got.
- Production uses a customer-managed KMS key; dev uses the AWS-managed default—and nobody notices until a cross-account copy fails.
- Three copies of the same VPC module with slightly different variable names.
The fix is not “be more careful.” It is one module source, thin environment roots, and promotion that changes inputs—not copy-paste.
Repository layout
A pattern that has survived multiple accounts and regions:
terraform/
├── modules/
│ ├── network/
│ ├── ecs-service/
│ ├── batch/
│ └── observability/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ └── prod/
└── deployment/ # optional: region-specific tfvars only
├── eu-central-1/
│ ├── dev.tfvars
│ └── prod.tfvars
└── us-east-1/
└── prod.tfvars
modules/ holds reusable building blocks. Each module should do one job: a VPC, an ECS service, a Batch compute environment—not “everything for this product.”
environments/{dev,staging,prod}/ are thin roots. They wire modules together, pass environment-specific sizing, and point at the right backend key. They should read like configuration, not novel infrastructure logic.
deployment/ (optional) is for region-specific values only—VPC IDs, subnet lists, AMI overrides, image tags—when the same environment root deploys to more than one region.
What goes in a module vs. an environment root
| Belongs in the module | Belongs in the environment root |
|---|---|
| Resource definitions and sensible defaults | Which modules to instantiate and in what order |
| Variables with validation blocks | Environment name, account ID, region |
| Outputs other stacks might consume | Remote state data sources for shared network |
| IAM policy documents (parameterized) | terraform.tfvars for instance sizes, counts |
Tags applied via default_tags or locals | Backend configuration |
Never put secrets in terraform.tfvars committed to git. Use SSM, Secrets Manager, or CI-injected -var for tokens. If a value rotates, it is not Terraform code.
Naming and tagging conventions
Every resource should carry at least:
Environment—dev,staging,prodProjectorApplication— the service or platform nameManagedBy—terraformOwner— team or cost-center contact
Module names should match what they create: modules/batch-compute, not modules/infrastructure-v2-final. State keys should mirror environment and region: prod/eu-central-1/network, not terraform.tfstate in a shared bucket with no prefix.
Use terraform.workspace or explicit backend keys—pick one model and document it. Mixing both without a convention confuses everyone on day two.
Remote state
One S3 bucket (per organization or account partition) with separate keys per environment and region is enough for most teams:
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "prod/eu-central-1/platform"
region = "eu-central-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Split state when blast radius demands it—network in one state, application in another—not because “micro-states are trendy.” Cross-stack references via terraform_remote_state are fine; undocumented coupling is not.
Promotion workflow: dev → staging → prod
Changes should flow like this:
- Plan and apply in dev with the same module version staging will use.
- Open a PR that bumps module source ref (if using git tags) or documents the module change—not a forked copy of the module.
- Plan staging with production-sized variables where it matters (e.g., multi-AZ in staging if prod is multi-AZ).
- Plan prod from the identical module source; only
tfvarsand backend key differ.
Avoid maintaining “prod hotfix” Terraform in a separate branch that never merges back. If prod needed a one-off, the module was wrong or incomplete.
When CI/CD owns image tags (see the ECS deploy checklist), decide explicitly whether task definitions live in Terraform or the pipeline—not both silently.
Common mistakes
Hard-coded ARNs in modules — Pass IDs as variables. ARNs change across accounts and regions.
Duplicated locals in every environment root — If three roots define the same local.common_tags, move it to a small locals module or shared versions.tf.
State bucket in the wrong account — Prod state should not live in a personal sandbox account “temporarily.”
One giant terraform apply for everything — Network, data, and compute can share a repo but not always one state file. A bad RDS change should not block a log group tweak.
No default_tags provider block — AWS provider default_tags catches resources that forget explicit tags.
Checklist before adding a new environment
- New root under
environments/—not a copy of prod with find-and-replace. - Backend key includes environment and region.
-
tfvarsdocument required inputs; no secret values in git. - Module
sourcepoints at the same path or tag as existing envs. - Plan output reviewed by someone who did not write the change.
- Tagging validated on one resource in the console after first apply.
What’s next
Terraform tells you what got deployed; CloudWatch Logs Insights tells you what broke afterward. Next in this series: eight queries I reuse in production for error spikes, ECS task stops, and the first fifteen minutes of an incident.
If you only do one thing: one module source, three thin environment roots—never three forks of the same VPC module.