All modules in this track (11)
In this module, you'll learn to:
- Keep reusable child modules apart from the root modules that deploy them
- Split each environment into numbered state layers, and explain what that protects
- Pin versions exactly in production roots and as ranges in child modules
Before you start: you should be comfortable writing basic Terraform: resources, variables, terraform plan, and terraform apply. This is the first module of the track.
The two types of Terraform modules
Mixing up a reusable child module and a root deployment module is one of the most common reasons a Terraform codebase becomes hard to maintain.
Keep the two apart:
- Child modules (the building blocks): reusable blueprints driven by inputs. They don't declare a backend, hardcode account IDs, or mention a specific environment. They're versioned and released like software libraries.
- Root modules (the deployments): the real environments. They set the S3 state backend and the provider (account, region, credentials), call the child modules, and pass in environment-specific values from
.tfvars.
The canonical directory layout
Organize the repository into clear layers of responsibility:
terraform-root/
├── modules/ # Reusable child modules (pure blueprints, versioned)
│ ├── vpc/
│ │ ├── main.tf # VPC, subnets, route tables, flow logs
│ │ ├── variables.tf # Inputs with validation & type constraints
│ │ ├── outputs.tf # Exports VPC ID, subnet IDs, CIDR blocks
│ │ ├── versions.tf # Min provider/core constraints (~> 5.0)
│ │ └── README.md # Generated documentation with inputs/outputs
│ ├── aurora-postgresql/
│ └── eks-cluster/
│
└── environments/ # Root execution environments (where state lives)
├── prod/
│ ├── 00-bootstrap/ # S3 state bucket and KMS keys
│ │ ├── backend.tf # (Local backend during first run, then migrated)
│ │ └── main.tf
│ ├── 01-networking/ # Foundation: VPC, Transit Gateway, Route 53
│ │ ├── backend.tf # S3 backend with native locking (use_lockfile)
│ │ ├── main.tf # Calls modules/vpc with prod parameters
│ │ ├── providers.tf # Provider with exact version pin
│ │ ├── terraform.tfvars # Prod CIDRs, tags, AZs
│ │ ├── variables.tf
│ │ └── outputs.tf # Writes IDs to AWS SSM Parameter Store
│ ├── 02-security/ # Organization IAM roles, GuardDuty, Security Hub
│ │ ├── backend.tf
│ │ └── main.tf
│ ├── 03-data/ # Aurora DB, DynamoDB tables, S3 lakes, EFS
│ │ ├── backend.tf
│ │ └── main.tf
│ └── 04-compute/ # EKS clusters, Node Groups, ALB, ECS services
│ ├── backend.tf
│ └── main.tf
│
└── nonprod/ # Parity with prod; identical 5-layer topology
├── 00-bootstrap/
├── 01-networking/
├── 02-security/
├── 03-data/
└── 04-compute/
The 5-Layer Enterprise Slicing Standard
Why slice environments into numbered layers instead of one monolithic deployment?
| Layer | Responsibility & Components | Volatility | Blast Radius & Team Ownership |
|---|---|---|---|
| 00-bootstrap | S3 state bucket and KMS keys | Once (Creation only) | Highest (Platform Admin / Security Lead |
| 01-networking | VPCs, Subnets, Transit Gateway, Route 53 Resolver | Quarterly (Slow moving) | High) Network & Foundation Platform Team |
| 02-security | Base IAM roles, GuardDuty, Security Hub, KMS keys | Monthly (Compliance) | High (SecOps & Platform Team |
| 03-data | Aurora PostgreSQL, DynamoDB, S3 lakes, ElastiCache | Monthly (Planned maintenance) | Extreme) Database & Platform Storage Team |
| 04-compute | EKS clusters, node groups, ALBs, ECS tasks, Lambdas | Daily / Weekly (Fast moving) | Contained: Application & DevOps Teams |
What layering gives you
- Separate state and locks: running
terraform applyin04-computelocks only the compute state. The database and networking states aren't touched, so a compute change can't accidentally modify them. - Separate permissions: in CI/CD (for example GitHub Actions with AWS OIDC), application teams assume an IAM role scoped to
04-computeonly. That role can't change01-networkingor03-data. - Faster plans: each layer holds tens of resources instead of hundreds, so
terraform planfinishes much faster.
Key rules for the standard module layout
- Always include
versions.tfin every module: Pin the minimum compatible Terraform CLI and provider versions. - Always output resource IDs and ARNs: A module that creates an S3 bucket or IAM role must output both
.idand.arnso downstream modules can bind policies without querying data sources. - One primary job per module: A module should represent a single logical system boundary (e.g.
vpc,aurora-cluster,eks-cluster), not an entire datacenter in one file.
Exact pins in roots, ranges in modules
Where you declare a version matters as much as which version you pick:
1. In child modules: allow a range with ~>
The pessimistic constraint operator ~> lets a module accept compatible minor updates, so it works in more places:
# modules/vpc/versions.tf
terraform {
required_version = ">= 1.5.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30" # Accepts 5.30.x and 5.99.x, but rejects 6.0.0
}
}
}
2. In production roots: pin exact versions with =
A production plan should give the same result every time. If a provider release changes behaviour overnight, your pipeline shouldn't pick it up by surprise:
# environments/prod/01-networking/versions.tf
terraform {
required_version = "= 1.11.4" # 1.11+: native S3 state locking is generally available
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 5.48.0" # Strictly pinned for reproducible plans
}
}
}
Anti-patterns to avoid
Anti-Pattern 1: The "Monolithic State Monster"
Putting VPC networking, Aurora clusters, IAM roles, and EKS deployments in a single main.tf file.
- Why it fails: one typo in an IAM policy can put the database in the same plan, and
terraform plangets slow across hundreds of resources. - Fix: split state into separate layers (
01-networking,02-security,03-data,04-compute).
Anti-Pattern 2: Hardcoding Environment Logic Inside Modules
Writing if var.env == "prod" inside a reusable module.
- Why it fails: the module now only works for the environments it knows about, and every new environment means editing the module.
- Fix: pass the setting in as a variable instead (for example
retention_in_days = var.log_retention_days).
Anti-Pattern 3: Unpinned Modules in Git
Referencing modules using source = "git::https://github.com/.../my-module.git" without a tag or commit ref.
- Why it fails: Any push to
mainin the module repo immediately changes future plans in production. - Fix: always pin to a release tag such as
?ref=v1.4.2, or a commit SHA.
Key terms
- Child module: a reusable blueprint driven by inputs. No backend, no account IDs, no environment names.
- Root module: a real deployment. It sets the backend and provider, calls child modules, and passes in each environment's values.
- State layer: a numbered root (
00-bootstrapto04-compute) with its own state, its own lock, and its own CI role. ~>constraint: accepts compatible updates within a major version.~> 5.30allows 5.30 and later 5.x, but not 6.0.- Exact pin (
=): used in production roots, so a new provider release can't change a plan by surprise.
Pop quiz
Five questions: three on the ideas in this module, and two scenarios where you apply them. The order changes every time you take it, and 4 out of 5 passes.
5 questions. Decide on your answer to each, then check it.
-
Concept
Which of these belongs in a root module, not in a reusable child module?
Show answer
The S3 backend configuration
Root modules decide where state lives and which account and region to use. Child modules only take inputs and return outputs.
-
Concept
Why does an application team's CI role get access to
04-computeonly?Show answer
A mistake or a compromised pipeline can't change the networking or data layers
Each layer has its own state and its own role, so a compute change can't reach the network or the database.
-
Concept
A child module declares
version = "~> 5.30"for the AWS provider. Which version does it reject?Show answer
6.0.0
~> 5.30accepts 5.30 and any later 5.x release, but not the next major version. -
Scenario
A teammate adds
var.env == "prod" ? 365 : 30inside the sharedvpcmodule to keep flow logs longer in production. What do you suggest instead?Show answer
A
flow_log_retention_daysinput, set per environment in.tfvarsA module that checks environment names only works for the environments it knows. An input keeps it reusable, and each root decides its own value.
-
Scenario
Production plans suddenly show changes nobody made, right after someone pushed to a module repository's main branch. The root uses
source = "git::https://.../vpc.git". What's the fix?Show answer
Pin the module source to a release tag such as
?ref=v1.4.2, or a commit SHAWithout a ref, every push to the module repository changes what production plans. Pin it, and upgrade on purpose.