Skip to content

Terraform Module Design

Principles for writing opinionated, reusable Terraform modules with sensible defaults and consistent conventions

Related Concepts: Terraform Module Philosophy | Coupling and Cohesion | Terraform Project Structure

Design Principles

Modules should be opinionated, not thin wrappers. A module that simply proxies every input to a single resource adds indirection without value. Encode decisions — sensible defaults, tagging strategy, naming conventions, security posture — so that callers get a well-considered component, not a bag of knobs.

  • Small surface area — expose only the inputs that genuinely vary between callers. Prefer a focused interface over input field bloat. If a value is the same in every environment, hardcode it in the module.
  • One clear purpose — a module should do one thing well. A WAF module manages web ACLs and rate limiting. It doesn't also configure the ALB or manage DNS. If a module needs a "type" variable to switch between unrelated behaviors, it's two modules.
  • Encode opinions — choose sane defaults and enforce them. Tag strategy, naming patterns (${var.namespace}-${var.name}), visibility config, encryption settings — these are decisions the module makes so callers don't have to.
  • Predictable behavior — use dynamic blocks for conditional behavior (e.g. optional scope_down_statement) and optional() in variable type constraints. The module should behave consistently whether an optional field is provided or omitted.
  • Observable — enable CloudWatch metrics, access logging, and sampled requests by default. Infrastructure should reveal its state without the caller having to remember to turn observability on.

Data-Only Modules

Sometimes it's necessary to create modules that contain no infrastructure — no resource blocks at all. These data-only modules contain only data sources, locals, variable inputs, and output values. They exist to encapsulate reusable lookup logic or computed conventions that would otherwise be duplicated across root modules.

Data-only modules are safe to apply repeatedly (no mutations, no drift), trivially testable (just assert on outputs), and composable with any root module or resource-creating module.

Use them when the same lookup or computation appears in multiple root modules. If only one root module needs it, inline data blocks are simpler.

Network Lookup

Multiple root modules within an account need to discover the same VPC, subnets, and availability zones. Rather than each root module repeating the same data "aws_vpc", data "aws_subnets", and filter logic, a modules/network_lookup module centralizes it. If the tagging convention changes, you fix it in one place. Every consumer calls module "network" and gets consistent IDs.

hcl
# modules/network_lookup/main.tf
variable "environment" {
  type = string
}

data "aws_vpc" "this" {
  tags = { Environment = var.environment }
}

data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.this.id]
  }
  tags = { Tier = "private" }
}

output "vpc_id"             { value = data.aws_vpc.this.id }
output "private_subnet_ids" { value = data.aws_subnets.private.ids }

Naming and Tagging Conventions

A pure-computation module (no data sources, no provider needed) that takes namespace, environment, and team as inputs and outputs a standard prefix string and tags map. This enforces naming and tagging policy across every root module without each one reimplementing the same locals block. Changes to the convention propagate from a single source of truth.

hcl
# modules/conventions/main.tf
variable "namespace"   { type = string }
variable "environment" { type = string }
variable "team"        { type = string }

locals {
  prefix = "${var.namespace}-${var.environment}"
  tags = {
    Name        = local.prefix
    Environment = var.environment
    Team        = var.team
    ManagedBy   = "Terraform"
  }
}

output "prefix" { value = local.prefix }
output "tags"   { value = local.tags }

Tagging Strategy

Tags exist for debugging and discoverability. When someone who isn't intimately familiar with the provisioned resources is working in the AWS console, they should be able to rely on tags to understand what a resource is for and how it was provisioned.

Conventions

  • Use PascalCase for keys and kebab-case for values.
  • Do not put PII or sensitive data in tags — they're accessible to many AWS services including billing.
  • Use too many tags rather than too few.

Required Tags

TagDescriptionExample Values
EnvironmentThe deployment environmentdev, uat, prod, global, shared
ProvisionedByHow the resource was createdterraform (always — reserved for future tools like Pulumi)
ModuleThe module that created the resourceterraform-aws-ecs-service, or root if deployed directly from a root module
ModuleVersionVersion ref of the module0.0.1, or local for in-repo modules

Optional Tags

TagDescriptionExample Values
ApplicationNameThe application service tied to these resourcesbackend-api

Applying Default Tags at the Root Module Level

We prefer to apply a base set of tags at the root module level using the provider's default_tags block. This ensures every resource in the root module gets tagged consistently without each module or resource needing to manage it.

hcl
provider "aws" {
  region = "us-west-2"

  default_tags {
    tags = {
      "Environment"   = "prod"
      "ProvisionedBy" = "terraform"
      "Module"        = "root"
      "ModuleVersion" = "local"
    }
  }
}

Modules that create resources can layer additional tags on top (e.g. Name, ApplicationName) — provider default tags merge with resource-level tags, with resource-level tags taking precedence on conflict.

hcl
variable "tags" {
  type = map(string)
  default = {
    "Environment"     = "dev"
    "ProvisionedBy"   = "terraform"
    "Module"          = "terraform-aws-ecs-service"
    "ModuleVersion"   = "0.0.1"
    "ApplicationName" = "backend-api"
  }
}

resource "aws_instance" "web" {
  ami           = "ami-08f7912c15ca96832"
  instance_type = "t3.micro"
  tags          = var.tags
}

Migration Notes

If you encounter existing resources with an Owner tag set to a person's name, replace it with ProvisionedBy = "terraform". Infrastructure ownership is a team concern, not an individual one.