Quick answer: A Terraform module is a folder of .tf files with defined inputs (variables) and outputs that you call from other configurations with a module block. You create one by moving related resources into their own directory and exposing the values callers need; you use one by setting source to a local path, Git repository or the Terraform Registry and passing arguments. Modules are how teams stop copy-pasting the same VPC or Kubernetes cluster code into every project.
This guide builds a real VPC module from scratch, shows the standard file layout, explains every type of module source, covers versioning and provider handling, and lists the design mistakes that make modules painful to maintain. By the end you will be able to write a module your colleagues can consume without reading its internals.
What a module is β and what it is not
Every Terraform configuration is already a module. The directory where you run terraform apply is the root module. Any directory it calls via a module block is a child module. There is no special declaration; a module is simply a folder with .tf files, which is why the concept feels light once it clicks.
A module is not a provider (that is a plugin that talks to an API) and not a workspace (that is a separate state for the same code). Think of a module as a function: inputs go in, resources are created, outputs come back.
Standard module layout
| File | Contents |
|---|---|
main.tf |
The resources the module creates |
variables.tf |
Input variable declarations with types, descriptions and defaults |
outputs.tf |
Values exposed to the caller |
versions.tf |
required_version and required_providers constraints |
README.md |
Usage example and input/output table (generate with terraform-docs) |
examples/ |
Small root configurations that call the module, doubling as tests |
Terraform does not enforce these names β it merges every .tf file in the folder β but the Registry, most teams and tools such as terraform-docs expect them.
Hands-on: writing a VPC module
Create modules/vpc/ with three files. First, the inputs in variables.tf:
variable "name" {
type = string
description = "Name prefix for all network resources"
}
variable "cidr_block" {
type = string
description = "CIDR range for the VPC"
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "cidr_block must be a valid IPv4 CIDR."
}
}
variable "azs" {
type = list(string)
description = "Availability zones to spread subnets across"
}
variable "tags" {
type = map(string)
description = "Tags applied to every resource"
default = {}
}
Then the resources in main.tf, using for_each so each subnet has a stable key:
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
tags = merge(var.tags, { Name = "${var.name}-vpc" })
}
resource "aws_subnet" "private" {
for_each = { for idx, az in var.azs : az => idx }
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.cidr_block, 8, each.value)
tags = merge(var.tags, { Name = "${var.name}-private-${each.key}" })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, { Name = "${var.name}-igw" })
}
Finally, outputs.tf exposes what callers will need:
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Map of availability zone to private subnet ID"
value = { for az, s in aws_subnet.private : az => s.id }
}
Notice the module contains no provider block. Child modules inherit providers from the root; defining one inside a module makes it impossible to remove the module cleanly later.
Calling the module
From the root configuration, a module block names the source and passes arguments that match the variables:
module "network" {
source = "./modules/vpc"
name = "tkh-prod"
cidr_block = "10.20.0.0/16"
azs = ["ap-south-1a", "ap-south-1b"]
tags = { Environment = "prod", ManagedBy = "terraform" }
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = module.network.private_subnet_ids["ap-south-1a"]
}
Outputs are read as module.<label>.<output>. Run terraform init after adding or changing a module block so Terraform can install it. Every argument you pass flows through the variable system described in Using Variables and Expressions in Terraform.
Module sources: local, Git and Registry
- Local path β
source = "./modules/vpc". Fastest for development; no versioning. - Terraform Registry β
source = "terraform-aws-modules/vpc/aws"withversion = "~> 5.0". Thousands of community modules; theversionargument works only with registry sources. - Git β
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v1.4.0". The double slash selects a subdirectory andrefpins a tag, branch or commit. Always pin a tag in production. - Private registry β HCP Terraform, Terraform Enterprise, GitLab and others host organisation-only modules with the same
source/versionsyntax.
Publishing to the public Registry requires a GitHub repository named terraform-<PROVIDER>-<NAME> and semantic version tags. The full publishing and versioning workflow is covered in Terraform Module Versioning and Publishing: A Complete Guide.
Designing modules people want to use
- Do one thing. A “network” module and an “eks-cluster” module compose better than one “everything” module with 80 inputs.
- Give every variable a type and description; provide sensible defaults for optional inputs and none for required ones.
- Expose generous outputs. Callers cannot reach inside a module, so if they might need the route table ID, output it.
- Prefer
for_eachovercountso adding or removing one item does not re-index and recreate the rest. - Pin provider constraints in
versions.tfbut never configure providers inside the module. - Ship an
examples/folder and, in Terraform 1.6+, atests/folder using the nativeterraform testcommand.
Common module mistakes
- Defining a
providerblock inside the module. Terraform warns about this for good reason: the module can no longer be destroyed or counted, and multi-region use becomes impossible. Pass providers from the root with theprovidersargument instead. - Using an unpinned Git branch as the source.
ref=mainmeans every teammate may get different code. Pin a tag. - Over-abstracting too early. Writing a module for a single resource adds indirection without benefit. Modularise when you have the same pattern in two or three places.
- Outputting nothing. A module whose callers have to use
terraform state showto find IDs has failed at its only job. - Forgetting
terraform initafter changingsourceorversion. The error “Module not installed” almost always means this.
Frequently asked questions
Can a module have its own state file?
No. All modules called from a root configuration share that root’s state. If you want separate state, run the module as its own root configuration and connect them with terraform_remote_state data sources.
How do I create several copies of a module?
Use count or for_each on the module block (Terraform 0.13+). For example for_each = toset(["dev", "staging"]) creates one network per environment.
Can I use depends_on with a module?
Yes. A depends_on on the module block makes every resource inside wait. Use it sparingly, because it also delays reading the module’s outputs until apply time.
Should I write my own modules or use Registry ones?
Start with well-maintained Registry modules such as terraform-aws-modules/vpc/aws, then write thin wrappers that encode your organisation’s naming, tagging and security defaults.
Key takeaways
- A module is just a folder of
.tffiles with variables in and outputs out; the root configuration is itself a module. - Follow the
main.tf,variables.tf,outputs.tf,versions.tflayout and never configure providers inside a module. - Pin versions for Registry sources and tags for Git sources; re-run
initafter changing them. - Design small, single-purpose modules with typed inputs, rich outputs and
for_eachfor repeated resources.
Ready to build and publish production-quality Terraform modules for AWS and Azure as part of a real DevOps workflow? Our DevOps course covers Terraform end to end with live projects, mentor support and placement assistance. Prefer video? Follow along on our YouTube channel.



