Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 013 / 100

Terraform modules examples: patterns, pitfalls and fixes

Reusable building blocks for infrastructure.

Expona Admin7 min read

Terraform modules examples: patterns, pitfalls and fixes

As your infrastructure grows, copy-pasting Terraform code becomes a maintenance nightmare. Modules let you package reusable infrastructure blocks once and deploy them consistently across projects. In this article, we'll walk through real patterns used in production systems and show you how to avoid the traps that catch teams when they first adopt modular infrastructure.

What a module is — a folder of .tf files with inputs and outputs

At its core, a Terraform module is just a directory containing .tf files. The simplest modules have three key files:

  • main.tf — the resource definitions
  • variables.tf — input variables (the module's parameters)
  • outputs.tf — values you expose back to the caller

Think of modules like functions in programming. You define inputs, do some work, and return outputs. The caller doesn't need to know or care how the work happens inside.

Here's a minimal network module structure:

hcl
modules/
└── vpc/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf

Inside variables.tf, you declare what the module accepts:

hcl
variable "cidr" {
  type        = string
  description = "VPC CIDR block"
}

variable "az_count" {
  type        = number
  description = "Number of availability zones"
  default     = 2
}

variable "environment" {
  type        = string
  description = "Environment name (dev, staging, prod)"
}

In main.tf, you use those variables to create actual resources:

hcl
resource "aws_vpc" "main" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_subnet" "private" {
  count             = var.az_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.environment}-subnet-${count.index + 1}"
  }
}

Finally, expose what matters via outputs.tf:

hcl
output "vpc_id" {
  value       = aws_vpc.main.id
  description = "VPC ID"
}

output "subnet_ids" {
  value       = aws_subnet.private[*].id
  description = "List of private subnet IDs"
}

That's a module. Folders, variables, resources, outputs. No magic.

Call a module — pass variables and use outputs

Once you've written a module, calling it is straightforward. In your root Terraform configuration (or another module), use a module block:

hcl
module "vpc" {
  source = "./modules/vpc"

  cidr        = "10.0.0.0/16"
  az_count    = 3
  environment = "production"
}

module "database" {
  source = "./modules/rds"

  vpc_id           = module.vpc.vpc_id
  subnet_ids       = module.vpc.subnet_ids
  environment      = "production"
  instance_class   = "db.r6i.xlarge"
  allocated_storage = 100
}

Notice how the database module consumes outputs from the VPC module. That's composition. You build layers: networking, then databases, then services, each module depending on outputs from the layer below.

When you run terraform apply, Terraform parses the module block, reads the source directory, interpolates your variables, and creates all resources inside it. From your perspective, you call a module like you'd call a function — you pass arguments and get results.

In a real project, you'll have a directory structure like this:

hcl
production/
├── main.tf           # calls modules
├── variables.tf      # inputs to the root config
└── terraform.tfvars  # actual values (dev, staging, prod differ)
modules/
├── vpc/
├── rds/
├── security_group/
└── iam_role/

You can also source modules from remote locations — a Git repository or the Terraform Registry — using:

hcl
source = "git::https://github.com/myorg/terraform-modules.git//modules/vpc?ref=v1.2.0"

This lets teams share modules across multiple projects without duplicating code.

Registry modules — terraform-aws-modules saves time for common resources

The Terraform Registry (registry.terraform.io) hosts thousands of pre-built modules, maintained by HashiCorp, AWS, and the community. The terraform-aws-modules organization publishes some of the most reliable and battle-tested ones.

For example, instead of writing your own VPC module, you can use the official one:

hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "production-vpc"
  cidr = "10.0.0.0/16"

  azs              = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = true
  enable_dns_hostnames = true

  tags = {
    Environment = "production"
  }
}

This single block handles:

  • VPC creation
  • Public and private subnets across multiple AZs
  • NAT gateways for outbound traffic from private subnets
  • Internet gateway attachment
  • Route table configuration
  • VPN gateway (if enabled)

Writing that from scratch would take hours and introduce bugs. Registry modules are tested in production across thousands of deployments. They also evolve — when AWS releases new features, these modules usually add support within weeks.

However, registry modules come with a tradeoff: they're more opinionated and have more inputs than a custom module. Read the module's documentation carefully. Always pin the version using ~> 5.0 or 5.2.1 — never use the latest implicitly, as breaking changes can slip through.

Keep modules focused — one purpose per module

A common mistake is building "god modules" that do everything. Don't create a module called aws_infrastructure that provisions VPCs, databases, load balancers, and IAM roles all at once. That defeats the purpose of modularity.

Instead, follow the single responsibility principle:

  • vpc — networking only
  • rds — database only
  • eks — Kubernetes only
  • iam_role — IAM roles for a specific service
  • security_group — firewall rules

Then compose them together in your root configuration:

hcl
module "network" {
  source      = "./modules/vpc"
  cidr        = var.vpc_cidr
  environment = var.environment
}

module "app_sg" {
  source      = "./modules/security_group"
  vpc_id      = module.network.vpc_id
  name        = "app-servers"
  environment = var.environment
}

module "database" {
  source            = "./modules/rds"
  vpc_id            = module.network.vpc_id
  subnet_ids        = module.network.private_subnet_ids
  security_group_id = module.app_sg.id
  environment       = var.environment
}

module "eks_cluster" {
  source                = "./modules/eks"
  vpc_id                = module.network.vpc_id
  subnet_ids            = module.network.private_subnet_ids
  database_endpoint     = module.database.endpoint
  environment           = var.environment
}

This approach gives you flexibility. You can swap out the RDS module for DynamoDB without touching networking code. You can test each module independently. You can reuse the VPC module across five different projects without copying it.

Focused modules are also easier to version. When you bump a module to version 2.0 because of a breaking change, you only have to update the projects that actually use it.

Common mistakes

Overly complex variable validation: Don't add validation rules so strict that the module becomes hard to use. If you require 15 specific inputs with complex interdependencies, you've probably built a god module.

Hardcoded values in modules: If you write region = "us-east-1" inside a module instead of accepting it as a variable, your module only works in that region. Always parameterize things that might vary.

Forgetting about remote state: When modules live in a separate Git repo and teams call them from different projects, each project needs its own Terraform state. If you mix state files, terraform destroy becomes terrifying.

Not versioning modules: If you call a module with source = "git::https://github.com/myorg/modules.git//vpc" without a ref, you're pinning to the latest commit on main. Someone changes the module next week, and your production deploy breaks. Always use ?ref=v1.2.3.

Ignoring module outputs: If a module creates an RDS instance but doesn't output the endpoint, the calling code has to hardcode the database hostname or use data sources to look it up. That's brittle. Always expose the outputs your callers will need.

Putting too much logic in locals: Locals are convenient, but if a module has 20 local values doing complex calculations, it's hard to understand and debug. Keep locals simple; use variables for user-facing inputs.

Key takeaways

  • A module is a folder of .tf files with inputs (variables), logic (resources), and outputs. It's infrastructure as a function.
  • Call modules with a module block, pass variables, and consume outputs. Compose multiple modules in a root configuration to build complete systems.
  • Registry modules like terraform-aws-modules save time for common patterns, but always pin versions and read documentation.
  • Keep modules focused: one responsibility per module. This makes them testable, reusable, and versionable.
  • Always parameterize configurable values, expose outputs your callers need, and version modules in Git to prevent unexpected changes.
  • Use modules as your primary tool for sharing infrastructure code across teams and projects.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles