As Terraform code grows, you end up copying the same blocks: a DNS record set for every site, a monitoring check for every server, a bucket with the same policies. Modules package those patterns: write them once, with inputs and outputs, then reuse them anywhere with a few lines.
Basics: Terraform basics and Terraform state.
What a module is
Any directory with .tf files is a module. The directory where you run terraform is the root module. Modules you call from it are child modules.
infra/
├── main.tf # root module
└── modules/
└── website-dns/
├── main.tf # resources
├── variables.tf # inputs
├── outputs.tf # outputs
└── versions.tf # required providers
Write a module
# modules/website-dns/variables.tf
variable "zone_id" {
type = string
description = "DNS zone ID"
}
variable "domain" {
type = string
}
variable "server_ip" {
type = string
validation {
condition = can(regex("^\\d+\\.\\d+\\.\\d+\\.\\d+$", var.server_ip))
error_message = "server_ip must be an IPv4 address."
}
}
variable "proxied" {
type = bool
default = true
}
# modules/website-dns/main.tf
resource "cloudflare_dns_record" "apex" {
zone_id = var.zone_id
name = var.domain
type = "A"
content = var.server_ip
proxied = var.proxied
ttl = 1
}
resource "cloudflare_dns_record" "www" {
zone_id = var.zone_id
name = "www.${var.domain}"
type = "CNAME"
content = var.domain
proxied = var.proxied
ttl = 1
}
# modules/website-dns/outputs.tf
output "hostnames" {
value = [cloudflare_dns_record.apex.name, cloudflare_dns_record.www.name]
}
(Resource attribute names differ between provider versions. Check your provider’s documentation.)
Call it
# main.tf (root)
module "shop_dns" {
source = "./modules/website-dns"
zone_id = var.zone_id
domain = "shop.example.com"
server_ip = "203.0.113.10"
}
output "shop_hosts" {
value = module.shop_dns.hostnames
}
terraform init # required whenever you add or change module sources
terraform plan
Many instances with for_each
variable "sites" {
type = map(string) # domain => server IP
default = {
"blog.example.com" = "203.0.113.10"
"shop.example.com" = "203.0.113.11"
}
}
module "dns" {
source = "./modules/website-dns"
for_each = var.sites
zone_id = var.zone_id
domain = each.key
server_ip = each.value
}
Prefer for_each (stable keys) over count (indexes shift when you remove an item, causing unwanted replacements).
Module sources and versions
| Source | Example |
|---|---|
| Local path | source = "./modules/website-dns" |
| Terraform Registry | source = "terraform-aws-modules/vpc/aws" + version = "~> 5.0" |
| Git | source = "git::https://git.example.com/infra/modules.git//website-dns?ref=v1.2.0" |
Pin versions for registry and Git modules (version, or ?ref=v1.2.0), so upstream changes don’t surprise you. Review community modules before use: they create real infrastructure with your credentials.
Design tips
- Small, focused modules (one concern each) compose better than giant “do everything” modules.
- Clear inputs with types, descriptions, defaults and validation. Outputs for anything callers might need.
- Don’t configure providers inside reusable modules. Pass them from the root (
providers = { … }), and declarerequired_providersinversions.tf. - Keep environments separate at the root level (
envs/prod,envs/staging) calling the same modules with different variables. - When you move resources into a module, add
moved {}blocks, so Terraform doesn’t destroy and recreate them. See Terraform state.
Testing
terraform validateandterraform fmt -checkin CI.terraform test(built in since 1.6) runs.tftest.hclfiles that plan or apply a module, and check assertions.- Tools like tflint and Checkov catch errors and insecure settings.
Quick reference
| Task | How |
|---|---|
| Create | Directory with variables.tf, main.tf, outputs.tf |
| Use | module "x" { source = "./modules/x" … } |
| After changing sources | terraform init |
| Many copies | for_each = map |
| Pin | version = "~> 5.0" / ?ref=v1.2.0 |
| Refactor safely | moved {} blocks |
Related: OpenTofu and Pulumi basics.