Terraform

Creating and Using Modules in Terraform: A Complete Guide

AGAnurag Gupta05 Apr 2025 Β· Updated 04 Oct 2026 Β· 7 min read
Creating and Using Modules in Terraform: A Complete Guide

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" with version = "~> 5.0". Thousands of community modules; the version argument 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 and ref pins 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/version syntax.

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_each over count so adding or removing one item does not re-index and recreate the rest.
  • Pin provider constraints in versions.tf but never configure providers inside the module.
  • Ship an examples/ folder and, in Terraform 1.6+, a tests/ folder using the native terraform test command.

Common module mistakes

  1. Defining a provider block 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 the providers argument instead.
  2. Using an unpinned Git branch as the source. ref=main means every teammate may get different code. Pin a tag.
  3. 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.
  4. Outputting nothing. A module whose callers have to use terraform state show to find IDs has failed at its only job.
  5. Forgetting terraform init after changing source or version. 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 .tf files with variables in and outputs out; the root configuration is itself a module.
  • Follow the main.tf, variables.tf, outputs.tf, versions.tf layout and never configure providers inside a module.
  • Pin versions for Registry sources and tags for Git sources; re-run init after changing them.
  • Design small, single-purpose modules with typed inputs, rich outputs and for_each for 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.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta β†’
Keep reading

Related articles

Leave a Reply