Steven's Knowledge

Modules

Package Terraform code into reusable, versioned modules - local modules, the registry, and how to design module interfaces

Modules

A module is just a directory of .tf files. The directory you've been running terraform apply in is already a module — the root module. The interesting part is calling one module from another to package and reuse infrastructure patterns.

Why Modules

  • DRY. A VPC, an EKS cluster, an RDS database — these patterns repeat across environments. Define the pattern once.
  • Interface. Modules expose only the inputs they want consumers to set, hiding the dozen internal resources.
  • Versioning. Pin a module to a known-good version; upgrade deliberately.
  • Review surface. A change to a shared module gets reviewed once and rolls out to every consumer.

Anatomy of a Module

modules/database/
├── main.tf          # resources
├── variables.tf     # inputs (the public API)
├── outputs.tf       # outputs (also part of the public API)
├── versions.tf      # required_providers
└── README.md        # how to use it

A minimal database module:

# modules/database/variables.tf
variable "name" {
  type        = string
  description = "Identifier for the DB instance"
}

variable "vpc_id" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "instance_class" {
  type    = string
  default = "db.t3.medium"
}

variable "engine_version" {
  type    = string
  default = "16.2"
}

variable "allocated_storage" {
  type    = number
  default = 20
}

variable "tags" {
  type    = map(string)
  default = {}
}
# modules/database/main.tf
resource "aws_db_subnet_group" "this" {
  name       = "${var.name}-subnets"
  subnet_ids = var.subnet_ids
  tags       = var.tags
}

resource "aws_security_group" "this" {
  name   = "${var.name}-db"
  vpc_id = var.vpc_id
  tags   = var.tags
}

resource "random_password" "master" {
  length  = 32
  special = false
}

resource "aws_db_instance" "this" {
  identifier             = var.name
  engine                 = "postgres"
  engine_version         = var.engine_version
  instance_class         = var.instance_class
  allocated_storage      = var.allocated_storage
  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.this.id]
  username               = "admin"
  password               = random_password.master.result
  skip_final_snapshot    = true
  tags                   = var.tags
}
# modules/database/outputs.tf
output "endpoint" {
  value = aws_db_instance.this.endpoint
}

output "security_group_id" {
  value = aws_security_group.this.id
}

output "password" {
  value     = random_password.master.result
  sensitive = true
}

Calling a Module

# environments/production/main.tf
module "primary_db" {
  source = "../../modules/database"

  name              = "myapp-prod"
  vpc_id            = aws_vpc.main.id
  subnet_ids        = aws_subnet.private[*].id
  instance_class    = "db.r6g.xlarge"
  allocated_storage = 100

  tags = local.common_tags
}

# Read module outputs
output "db_endpoint" {
  value = module.primary_db.endpoint
}

References across module boundaries look like module.NAME.OUTPUT_NAME.

Module Sources

The source argument decides where Terraform fetches the code from.

SourceExampleWhen to use
Local pathsource = "../../modules/database"Modules inside your repo
Terraform Registrysource = "terraform-aws-modules/vpc/aws"Public modules
Private Registrysource = "app.terraform.io/myorg/database/aws"Internal modules via TFC/Enterprise
Gitsource = "git::https://github.com/myorg/tf-modules.git//database?ref=v1.4.0"Internal modules via plain git
S3 / GCSsource = "s3::https://s3.amazonaws.com/bucket/module.zip"Air-gapped or restricted networks

For Git and registry sources, always pin a version. Without ?ref=v1.4.0 or version = "~> 1.4" you'll get whatever's at HEAD — and your "no changes" PR will silently include the latest upstream changes.

Versioning Registry Modules

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5"                                # any 5.5.x; bump deliberately to 5.6

  name = "myapp-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
  single_nat_gateway = true

  tags = local.common_tags
}

Common version constraint operators:

ConstraintAllows
= 1.4.2exactly 1.4.2
>= 1.4.01.4.0 and above (dangerous; no upper bound)
~> 1.4.01.4.x (patch updates)
~> 1.41.x where x >= 4 (minor updates)

Designing a Good Module Interface

A few rules that pay off:

  1. Small, opinionated modules beat big "do everything" modules. A vpc module, a database module, an eks_cluster module — composed in the root.
  2. Required inputs should have no default. Optional inputs should. Force the caller to be explicit about identity (name, environment), give them defaults for tuning (instance_class).
  3. Validate inputs. validation blocks catch typos at plan time, not 10 minutes into apply.
  4. Output what you'd need to compose. IDs, ARNs, endpoints, security group IDs. Don't expose internals callers shouldn't reach into.
  5. Don't configure providers inside a reusable module. The caller does that. Modules that declare provider {} blocks become hard to use in multi-region setups.

When NOT to Modularize

Premature modules cost more than copy-paste. Heuristics:

  • One environment? Don't modularize yet.
  • Two environments? Copy and adapt — see what actually differs.
  • Three or more, and the differences are small? Now extract.

A module written before you understand the real variation tends to lock in the wrong interface.

What's Next

You can compose infrastructure from versioned, reusable pieces. Now learn the HCL features that make modules and resources scale — meta-arguments, expressions, and lifecycle controls → Advanced Patterns.

On this page