Writing Reusable Terraform Modules: A Practical Guide
The difference between Terraform code that scales and Terraform code that becomes a maintenance nightmare usually comes down to one thing: whether the team invested in proper modules from the start. This guide walks through the practical decisions — directory layout, input validation, output design, and versioning — that separate a real module from a copy-paste shortcut wrapped in a folder.
What Makes a Module Actually Reusable
A reusable module has three properties:
- A clean interface — callers only need to know inputs and outputs, not what is inside.
- Sensible defaults — it works for 80% of cases with minimal configuration.
- Escape hatches — the remaining 20% can override behavior without forking the module.
Most teams write "modules" that are really just extracted resource blocks. Those are fine for DRY code but break down when you try to use them across teams or environments because every assumption is baked in as a hardcoded value.
Module Directory Structure
modules/
aws-eks-cluster/
main.tf # Resource definitions
variables.tf # Input declarations
outputs.tf # Output declarations
versions.tf # Provider and Terraform version constraints
README.md # Usage examples (generate with terraform-docs)
aws-rds-postgres/
main.tf
variables.tf
outputs.tf
versions.tf
README.md
Keep each module in its own directory with exactly these four files. Do not put multiple modules in one directory — it confuses terraform-docs and makes versioning impossible when you move to a registry.
Writing the variables.tf File Properly
This is where most modules go wrong. Variables without descriptions, without types, and without validation are a trap for every future caller.
# modules/aws-eks-cluster/variables.tf
variable "cluster_name" {
description = "Name of the EKS cluster. Must be unique within the AWS account and region."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,40}$", var.cluster_name))
error_message = "cluster_name must be 3-40 lowercase alphanumeric characters or hyphens."
}
}
variable "kubernetes_version" {
description = "Kubernetes version for the EKS cluster control plane."
type = string
default = "1.29"
validation {
condition = contains(["1.28", "1.29", "1.30"], var.kubernetes_version)
error_message = "kubernetes_version must be one of: 1.28, 1.29, 1.30."
}
}
variable "node_groups" {
description = "Map of node group configurations."
type = map(object({
instance_types = list(string)
min_size = number
max_size = number
desired_size = number
disk_size_gb = optional(number, 50)
labels = optional(map(string), {})
taints = optional(list(object({
key = string
value = string
effect = string
})), [])
}))
}
variable "tags" {
description = "Tags to apply to all resources created by this module."
type = map(string)
default = {}
}
Three habits to build here:
- Every variable has a
description. - Complex types use
object()notany. - Use
optional()for fields that have sensible defaults within a nested object.
Building the Main Resource File
# modules/aws-eks-cluster/main.tf
locals {
common_tags = merge(var.tags, {
ManagedBy = "terraform"
Module = "aws-eks-cluster"
})
}
resource "aws_eks_cluster" "this" {
name = var.cluster_name
version = var.kubernetes_version
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = var.subnet_ids
endpoint_private_access = true
endpoint_public_access = var.enable_public_endpoint
public_access_cidrs = var.public_access_cidrs
}
tags = local.common_tags
depends_on = [
aws_iam_role_policy_attachment.cluster_policy
]
}
resource "aws_eks_node_group" "this" {
for_each = var.node_groups
cluster_name = aws_eks_cluster.this.name
node_group_name = each.key
node_role_arn = aws_iam_role.node.arn
subnet_ids = var.subnet_ids
instance_types = each.value.instance_types
disk_size = each.value.disk_size_gb
scaling_config {
min_size = each.value.min_size
max_size = each.value.max_size
desired_size = each.value.desired_size
}
dynamic "taint" {
for_each = each.value.taints
content {
key = taint.value.key
value = taint.value.value
effect = taint.value.effect
}
}
labels = each.value.labels
tags = local.common_tags
}
The for_each on node groups is the escape hatch principle in action — callers can define any number of node groups with different specs without touching the module code.
Outputs That Are Actually Useful
# modules/aws-eks-cluster/outputs.tf
output "cluster_name" {
description = "Name of the EKS cluster."
value = aws_eks_cluster.this.name
}
output "cluster_endpoint" {
description = "HTTPS endpoint for the EKS control plane API."
value = aws_eks_cluster.this.endpoint
}
output "cluster_certificate_authority" {
description = "Base64-encoded certificate authority data for the cluster."
value = aws_eks_cluster.this.certificate_authority[0].data
sensitive = false
}
output "cluster_iam_role_arn" {
description = "ARN of the IAM role used by the EKS control plane."
value = aws_iam_role.cluster.arn
}
output "node_group_arns" {
description = "Map of node group name to ARN."
value = { for k, v in aws_eks_node_group.this : k => v.arn }
}
Rule of thumb: output everything a caller might need to chain into another module. Cluster name and endpoint are obvious. IAM role ARNs and security group IDs are less obvious but just as necessary.
Calling the Module from an Environment
# environments/staging/main.tf
module "eks" {
source = "../../modules/aws-eks-cluster"
# Or from a registry:
# source = "app.terraform.io/myorg/eks-cluster/aws"
# version = "~> 2.1"
cluster_name = "myapp-staging"
kubernetes_version = "1.29"
subnet_ids = module.vpc.private_subnet_ids
node_groups = {
general = {
instance_types = ["t3.large"]
min_size = 2
max_size = 6
desired_size = 3
}
compute = {
instance_types = ["c6i.2xlarge"]
min_size = 0
max_size = 10
desired_size = 0
labels = {
workload = "compute-intensive"
}
taints = [{
key = "workload"
value = "compute-intensive"
effect = "NO_SCHEDULE"
}]
}
}
tags = {
Environment = "staging"
Team = "platform"
}
}
The caller only sees the interface. They do not care how IAM roles are structured internally or how the cluster security groups are wired. That is the module abstraction working correctly.
Versioning Strategy
| Stage | Source Reference | When to Use |
|---|---|---|
| Local development | source = "../../modules/eks" | Building and testing the module |
| Shared but pre-registry | source = "git::ssh://[email protected]/org/modules.git//eks?ref=v1.2.0" | Small teams, no Terraform Cloud |
| Registry (recommended) | source = "app.terraform.io/org/eks/aws" version = "~> 1.2" | Production multi-team usage |
| Public registry | source = "terraform-aws-modules/eks/aws" version = "~> 20.0" | Commodity infrastructure |
Use semantic versioning. Breaking input changes are major versions. New optional inputs are minor. Bug fixes are patches. Tag your Git refs accordingly.
Common Mistakes to Avoid
Hardcoding region or account IDs. Always use data sources:
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
locals {
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.name
}
Using count instead of for_each for named resources. If you use count and remove an element from the middle of the list, Terraform destroys and recreates every resource after it. for_each on a map or set uses stable keys.
Leaking provider configuration into modules. Modules should never contain provider blocks with configuration. Pass provider aliases in from the root module if you need multi-region deployments.
No versions.tf. Every module needs this:
# modules/aws-eks-cluster/versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
Without version constraints, a provider upgrade can silently break your module for anyone consuming it.
Testing Modules with Terratest
A module without tests is a module waiting to break silently:
// test/eks_cluster_test.go
func TestEKSCluster(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../examples/basic",
Vars: map[string]interface{}{
"cluster_name": fmt.Sprintf("test-%s", random.UniqueId()),
"kubernetes_version": "1.29",
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
clusterName := terraform.Output(t, terraformOptions, "cluster_name")
assert.Contains(t, clusterName, "test-")
}
Run these in CI against a dedicated test account. They are slow (EKS takes 12 minutes to provision) but they are the only thing that catches real provider breaking changes.
Summary
| Practice | Why It Matters |
|---|---|
| Typed variables with validation | Fail fast at plan time, not apply time |
for_each over count | Stable resource addressing |
| Output everything downstream needs | Avoid tight coupling between modules |
versions.tf with constraints | Prevents silent breakage on provider upgrades |
| Semantic versioning + tags | Safe consumption across teams |
| Terratest integration tests | Catches breaking changes before they reach production |
Reusable Terraform modules are not about being clever — they are about building a stable interface that other engineers and other teams can rely on without reading your source code. Invest the hour upfront in proper types, validation, and outputs. You will get it back tenfold when the fourth team asks to use your module without filing a support ticket.
Was this article helpful?
DevOps Educator
I break down complex DevOps concepts into things you can actually understand and use on Monday morning. Whether you're switching careers or leveling up, I write the guides I wish I had when I started.
Related Articles
Terraform Module Design Patterns for Large Teams
Battle-tested Terraform module patterns for teams — from file structure to versioning to composition. If it's not in code, it doesn't exist.
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.
Terraform CLI: Cheat Sheet
Terraform CLI cheat sheet with commands organized by workflow — init, plan, apply, destroy, state manipulation, imports, and workspace management.
Testing Terraform with Terratest: A Practical Guide
How to write unit and integration tests for Terraform modules using Terratest — because untested infrastructure is a liability.
Terraform Remote State: S3 Backends, Locking, Workspaces, and State Surgery
Everything you need to know about Terraform remote state — from setting up S3 backends with locking to workspace strategies and emergency state surgery.
Terraform "Error: No Valid Credential Sources Found" On AWS — Fixing Authentication And Provider Configuration Issues
If you've spent any time with Terraform and AWS, you've almost certainly run into this gem at some point: It's one of those errors that looks simple but ca...
More in Terraform
View all →Terraform "Error: Cycle" In Resource Dependencies — How To Detect And Break Circular References
If you've spent any meaningful time with Terraform, you've probably encountered this at the worst possible moment: Everything was working fine, you added o...
Terraform Dynamic Blocks For Managing Variable Infrastructure Resources
If you've been working with Terraform for a while, you've probably hit that wall where you need to create resources with configurations that vary based on...
Terraform Error: Invalid For_each Argument Must Be A Map Or Set Of Strings - Complete Fix Guide
If you've been working with Terraform's `for_each` argument, you've probably hit this error at least once. Don't worry – it's one of those frustrating but...
Fix Terraform 'Error Acquiring the State Lock' with DynamoDB
Resolve Terraform DynamoDB state lock errors caused by permission issues, missing tables, or misconfigured backends.