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 declare required_providers in versions.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 validate and terraform fmt -check in CI.
  • terraform test (built in since 1.6) runs .tftest.hcl files 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.

  • Terraform
  • DevOps
  • Infrastructure as Code