Terraform Module Layout I Use for Multi-Environment AWS

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 moduleBelongs in the environment root
Resource definitions and sensible defaultsWhich modules to instantiate and in what order
Variables with validation blocksEnvironment name, account ID, region
Outputs other stacks might consumeRemote state data sources for shared network
IAM policy documents (parameterized)terraform.tfvars for instance sizes, counts
Tags applied via default_tags or localsBackend 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:

  • Environmentdev, staging, prod
  • Project or Application — the service or platform name
  • ManagedByterraform
  • Owner — 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:

  1. Plan and apply in dev with the same module version staging will use.
  2. Open a PR that bumps module source ref (if using git tags) or documents the module change—not a forked copy of the module.
  3. Plan staging with production-sized variables where it matters (e.g., multi-AZ in staging if prod is multi-AZ).
  4. Plan prod from the identical module source; only tfvars and 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.
  • tfvars document required inputs; no secret values in git.
  • Module source points 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.

Share :

Related Posts

GitHub Actions to ECR to ECS: a Deployment Checklist

This post is for teams already running workloads on Amazon ECS who want a reliable GitHub Actions pipeline without reinventing the wheel each release. It is not a greenfield Kubernetes guide, and it assumes you have a working cluster, service, and task definition—not a blank AWS account.

Read More