Skip to content

Modules

First PublishedLast UpdatedByAtif Alam

A module is a reusable group of Terraform resources packaged together. Every Terraform configuration is technically a module (the root module), and you can call other modules from it.

  • Reuse — Define a VPC once, use it across dev, staging, and prod.
  • Encapsulation — Hide complexity behind a clean interface (variables in, outputs out).
  • Consistency — Teams use the same tested module instead of copy-pasting resources.
  • Versioning — Pin module versions so infrastructure changes are intentional.

A module is a directory of .tf files. The convention:

modules/
vpc/
main.tf # resources
variables.tf # input variables
outputs.tf # output values
README.md # documentation

variables.tf declares the module’s inputs: values the caller must (or can) pass in when using the module. Each variable block defines a name, optional description, type, and optional default.

Inside the module, you reference these as var.<name> (e.g. var.vpc_cidr). Variables let the same module be reused with different values (e.g. different CIDRs or environments) without changing the module’s resource definitions.

variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
}
variable "environment" {
description = "Environment name (e.g. dev, staging, prod)"
type = string
}
variable "public_subnets" {
description = "List of public subnet CIDRs"
type = list(string)
default = []
}

main.tf defines the module’s resources. These are the infrastructure objects created by the module.

You can reference module inputs (e.g. var.vpc_cidr) and other module outputs (e.g. module.vpc.vpc_id) within these resource blocks.

resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.environment}-public-${count.index}"
}
}

outputs.tf declares the module’s outputs: values the caller can use in the calling module. Each output block defines a name, optional description, and the value to expose.

Inside the calling module, you reference these as module.<name>.<output_name> (e.g. module.vpc.vpc_id). Outputs let the caller use the module’s results (e.g. the VPC ID) without knowing how they’re implemented.

output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}

To use a module, you add a module block in your root (or another module’s) configuration. You set source to where the module lives (local path, registry, Git, or S3) and pass arguments that map to the module’s input variables. After applying, you reference the module’s outputs as module.<module_label>.<output_name> in other resources or outputs.

A local module lives in your repo, typically under a subdirectory like modules/. Use it when the module is maintained in the same codebase and you don’t need to version it separately.

  • source — Relative path from the current .tf file to the module directory (e.g. ./modules/vpc). Terraform loads all .tf files in that directory as the module.
  • Arguments — Pass values for the module’s variables by name. Required variables must be set; optional ones can be omitted if they have defaults.
  • Outputs — After terraform apply, use module.<label>.<output_name> (e.g. module.vpc.public_subnet_ids) in other resources or in your root output blocks.
  • terraform init — Run terraform init (or terraform get) whenever you add or change a module’s source so Terraform can fetch or link the module.

Example: call the VPC module and pass inputs; then use its output in another resource.

module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "production"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}

Access the module’s outputs elsewhere in your configuration:

resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
# ...
}

The Terraform Registry hosts community and official modules:

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
}

Reference a module in a Git repo:

module "vpc" {
source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}

The //vpc points to a subdirectory; ?ref=v1.2.0 pins to a Git tag.

module "vpc" {
source = "s3::https://my-bucket.s3.amazonaws.com/modules/vpc.zip"
}

Always pin module versions to avoid surprises:

module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # allows 5.x but not 6.0
}
ConstraintMeaning
= 5.1.0Exact version
>= 5.0Minimum version
~> 5.1>= 5.1, < 6.0 (pessimistic)
>= 5.0, < 6.0Range

For local and Git modules, use Git tags (?ref=v1.2.0) instead of version.

Once a module is stable, you can share it across teams:

  • Private Terraform Registry — Host your own registry (e.g. Terraform Cloud private registry, or self-hosted) so teams use source = "myorg/vpc/aws" and version = "~> 1.0" with consistent versioning and changelogs.
  • Git repository with tags — A dedicated terraform-modules repo (or monorepo with a modules/ path) and SemVer tags (v1.0.0, v1.1.0) let consumers pin with ?ref=v1.0.0. Document inputs/outputs (e.g. with terraform-docs) and keep a CHANGELOG so teams know when to upgrade.
  • Monorepo with local paths — For a single repo containing many environments, source = "../../modules/vpc" is simple but couples all consumers to the same ref; use when you want one source of truth and coordinated upgrades.

Improvements over time: add testing (e.g. terraform test, Terratest) and SemVer (bump major for breaking changes, minor for new features, patch for fixes) so the wider org can adopt modules with confidence. See Best Practices — Testing and CI/CD for automation.

Modules communicate through variables (in) and outputs (out):

module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "production"
}
module "app" {
source = "./modules/app"
vpc_id = module.vpc.vpc_id # output from vpc module
subnet_id = module.vpc.public_subnet_ids[0]
}

The app module defines vpc_id and subnet_id as input variables; the vpc module exposes them as outputs.

  • Keep modules focused — One module = one logical unit (VPC, database, app). Don’t pack everything into one mega-module.
  • Document with description — Every variable and output should have a description.
  • Use validation blocks — Catch bad input early:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
  • Don’t hardcode providers in modules — Let the root module configure providers; modules inherit them.
  • Test modules — Use terraform plan against example configs, or tools like Terratest.
  • A module is a directory of .tf files with variables (in) and outputs (out).
  • Source from local paths, the Terraform Registry, Git repos, or S3.
  • Always pin versions — version = "~> 5.0" for registry modules, ?ref=v1.2.0 for Git.
  • Modules communicate through outputs — module.vpc.vpc_id.
  • Keep modules small, focused, and well-documented.