Back to blog
7 min read

Terraform Modules: How I Stopped Copy-Pasting Infrastructure

Practical guide to Terraform modules - when to create them, how to structure them, and patterns that work across teams.

TerraformIaCModulesAWSDevOps

I used to copy-paste Terraform code between projects. It worked until I had 15 projects with slightly different versions of the same EC2 setup. When a security fix was needed, I had to update all 15. Some got missed. Production had vulnerabilities.

Modules solved this. One source of truth, used everywhere.

When to Create a Module

Not everything needs to be a module. I create one when:

  • I'm copying the same resources across multiple projects
  • A pattern has stabilized and I'm confident it won't change drastically
  • Multiple resources work together as a logical unit

I don't create modules for:

  • Single resources with no associated complexity
  • Patterns I'm still experimenting with
  • One-off infrastructure

Starting with modules too early leads to premature abstraction. I wait until I've copy-pasted something at least twice before extracting it.

Module Structure

Every module I write follows this structure:

modules/web-server/
├── main.tf          # Resources
├── variables.tf     # Inputs
├── outputs.tf       # Outputs
├── versions.tf      # Provider constraints
└── README.md        # Usage documentation

Variables (The Module's API)

Variables define what consumers can configure. I always include descriptions and use validation where it matters:

variable "name" {
  description = "Name for the instance and related resources"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Must be a t3 instance type."
  }
}

variable "subnet_id" {
  description = "Subnet ID for the instance"
  type        = string
}

variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}

Good defaults reduce boilerplate for consumers. Required variables should be truly required.

Outputs (What Consumers Need)

Outputs expose values that other resources might need:

output "instance_id" {
  description = "ID of the EC2 instance"
  value       = aws_instance.this.id
}

output "private_ip" {
  description = "Private IP address"
  value       = aws_instance.this.private_ip
}

output "security_group_id" {
  description = "ID of the security group"
  value       = aws_security_group.this.id
}

I output anything that might be referenced downstream. It's easier to add outputs upfront than to add them later when someone needs them.

Calling Modules

From Local Path

module "web_server" {
  source = "./modules/web-server"

  name          = "api-server"
  instance_type = "t3.small"
  subnet_id     = module.vpc.private_subnet_ids[0]
  vpc_id        = module.vpc.vpc_id

  tags = {
    Environment = "prod"
    Team        = "platform"
  }
}

From Git

module "web_server" {
  source = "git::https://github.com/myorg/terraform-modules.git//web-server?ref=v1.2.0"

  name      = "api-server"
  subnet_id = var.subnet_id
  vpc_id    = var.vpc_id
}

Always pin versions in production. I use Git tags for versioning private modules.

From Terraform Registry

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
  # ...
}

The community has already built excellent modules for common patterns. I use them when they fit and wrap them when I need to enforce company standards.

Patterns That Work

Conditional Resources

variable "create_dns_record" {
  type    = bool
  default = false
}

resource "aws_route53_record" "this" {
  count = var.create_dns_record ? 1 : 0
  # ...
}

Multiple Instances with for_each

variable "servers" {
  type = map(object({
    instance_type = string
    subnet_id     = string
  }))
}

module "servers" {
  for_each = var.servers
  source   = "./modules/web-server"

  name          = each.key
  instance_type = each.value.instance_type
  subnet_id     = each.value.subnet_id
}

Wrapper Modules

When using community modules, I create thin wrappers that enforce company standards:

# modules/company-vpc/main.tf
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  # Company standards enforced
  enable_dns_hostnames = true
  enable_flow_log      = true  # Required by security

  tags = merge(var.tags, {
    ManagedBy = "Terraform"
  })
}

This way teams use module "vpc" { source = "./modules/company-vpc" } and automatically get compliant infrastructure.

Versioning Strategy

For private modules, I use Git tags:

git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

Then reference with ?ref=v1.0.0 in the source URL.

Version constraints I use:

ConstraintWhen I Use It
= 1.0.0Production - exact version
~> 1.0When I trust minor updates
>= 1.0.0, < 2.0.0When I need flexibility with a ceiling

Never use >= 1.0.0 without an upper bound in production. A major version bump could break everything.

Mistakes I've Made

Creating modules too early. I built an elaborate module for a pattern I used once. It was wasted effort. Now I wait for repetition.

Too many required variables. My first modules had 20+ required inputs. Nobody wanted to use them. Good defaults make modules usable.

Not outputting enough. I'd create a module, then someone needed an attribute I didn't output. Adding outputs later means touching every consumer. Output generously upfront.

Tight coupling. Modules that assume too much about their environment are hard to reuse. Keep them focused on one thing.

Testing Modules

At minimum, I validate every module:

cd modules/web-server
terraform init
terraform validate

For critical modules, I maintain working examples:

modules/web-server/
└── examples/
    └── basic/
        └── main.tf

These examples serve as documentation and can be used for integration testing.

Key Takeaways

  • Wait for repetition - Don't create modules until you've copy-pasted twice
  • Good defaults reduce friction - Minimize required variables
  • Output generously - Anything downstream might need becomes an output
  • Pin versions in production - Use exact versions or tight constraints
  • Wrap community modules - Enforce company standards through thin wrappers
  • Keep modules focused - One logical unit per module
  • Document with examples - Working examples are better than prose
BT

Written by Bar Tsveker

Senior CloudOps Engineer specializing in AWS, Terraform, and infrastructure automation.

Thanks for reading! Have questions or feedback?