Appearance
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
dynamicblocks for conditional behavior (e.g. optionalscope_down_statement) andoptional()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
| Tag | Description | Example Values |
|---|---|---|
Environment | The deployment environment | dev, uat, prod, global, shared |
ProvisionedBy | How the resource was created | terraform (always — reserved for future tools like Pulumi) |
Module | The module that created the resource | terraform-aws-ecs-service, or root if deployed directly from a root module |
ModuleVersion | Version ref of the module | 0.0.1, or local for in-repo modules |
Optional Tags
| Tag | Description | Example Values |
|---|---|---|
ApplicationName | The application service tied to these resources | backend-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.