Terraform module patterns that scale
How we structure Terraform for multi-environment and multi-team use: composable modules, minimal variables, and clear ownership.
Why module structure matters
Most Terraform codebases start simple and become unmaintainable. The root cause is almost always the same: modules that try to do too much, variables that leak implementation details, and no clear boundary between "platform" and "product" code.
Here's the pattern we use across every engagement — it scales from a single engineer to a team of 20.
The three-layer model
We split Terraform into three layers:
- Foundation — VPCs, IAM roles, DNS zones, shared security groups. Owned by platform. Changes infrequently.
- Service platform — EKS/ECS clusters, RDS parameter groups, ElastiCache clusters. Shared infra consumed by products. Changes on a weekly cadence.
- Product — Application-specific resources: S3 buckets, SQS queues, Lambda functions. Owned by product teams. Changes with every deploy.
Each layer lives in its own Terraform state file. Cross-layer references go through terraform_remote_state or SSM Parameter Store — never direct module calls.
Module interface design
A module's variables are its public API. Keep them minimal:
- Accept only what varies between deployments (name, environment, size).
- Default every optional variable — callers should be able to use a module with two lines.
- Never expose provider-specific internals (AMI IDs, availability zone names) as required variables.
module "api_service" {
source = "../../modules/ecs-service"
name = "api"
environment = "production"
image = "123456789.dkr.ecr.eu-west-2.amazonaws.com/api:v1.42"
cpu = 512
memory = 1024
}
Naming and tagging
Every resource gets a standard tag set applied by the module, not the caller. We use a locals block to derive them:
locals {
tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
Module = "ecs-service"
}
}
This means tags are never forgotten and never inconsistent.
State management
Use S3 + DynamoDB state locking. One state file per environment per layer. Never share state between environments — the blast radius of a misconfigured terraform destroy must be bounded.
Testing
We test modules with Terratest for integration tests and Terraform test for unit-style checks. At minimum, every module should have a examples/ directory that can be applied in CI.
Summary
Three layers, minimal interfaces, consistent tagging, isolated state. That's the pattern. The discipline is in maintaining the boundaries as teams and codebases grow.
If you'd like us to review or restructure your Terraform, get in touch.
Want help applying this to your infrastructure?
We work with startups and scale-ups on platform engineering, cloud infrastructure, and CI/CD. Book a call to discuss.
More from Strataform
EKS vs ECS: trade-offs for product teams
When to choose Kubernetes (EKS) over managed containers (ECS). Operational load, team size, and migration paths.
SLOs and alert fatigue: a practical guide
Defining SLOs that matter, burn-rate alerting, and avoiding noise so on-call stays actionable.
GitHub Actions pipelines that don't slow you down
Caching, matrix builds, reusable workflows, and security hardening for production CI/CD pipelines.