All insights
TerraformIaCPlatform Engineering

Terraform module patterns that scale

7 min read

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.

Book a discovery call