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
Write modules that hold opinions. Terraform Module Philosophy covers what separates an abstraction worth writing from a thin wrapper, and when a module earns extraction.
- 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, and encryption settings 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, with 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. A modules/network_lookup module centralizes the data "aws_vpc", data "aws_subnets", and filter logic that every one of them would otherwise repeat. 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, which are accessible to many AWS services including billing.
- Err on the side of more tags.
Required Tags
| Tag | Description | Example Values |
|---|---|---|
Environment | The deployment environment | dev, uat, prod, global, shared |
ProvisionedBy | How the resource was created | terraform (always; the tag exists 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, and resource-level tags take 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 belongs to the team.