Modules

Packaging resources into reusable units, module inputs and outputs as a public interface, sourcing from registries and Git, why pinning with a ref tag is not optional, and how deep to nest before it stops paying.

advanced 22 min lesson hands-on task included

Every configuration is already a module — the working directory is the root module. Extracting a child module is how you stop copying the same four resources into every project and start changing them in one place.


Topic 1: Anatomy

modules/
└── queue-with-backoff/
    ├── main.tf        # the resources
    ├── variables.tf   # inputs
    ├── outputs.tf     # return values
    └── README.md      # purpose, inputs, outputs, example

Terraform does not care about the filenames — only that they end in .tf. The three-file convention exists so anyone can find the interface without reading the implementation.

# variables.tf — the public interface
variable "queue_name" {
  description = "Name of the queue"
  type        = string
}

variable "max_receive_count" {
  description = "Times a message can be received before dead-lettering"
  type        = number
  default     = 5
}

A variable without a default is a required input. One with a default is optional. That distinction is your API contract, so write descriptions.

# main.tf
resource "aws_sqs_queue" "main" {
  name                       = "${var.name_prefix}-${var.queue_name}"
  visibility_timeout_seconds = var.visibility_timeout
  message_retention_seconds  = 345600   # 4 days
  receive_wait_time_seconds  = 20       # long polling

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
    maxReceiveCount     = var.max_receive_count
  })
}

resource "aws_sqs_queue" "dead_letter" {
  name                      = "${var.name_prefix}-${var.queue_name}-dead-letter"
  message_retention_seconds = 1209600   # 14 days
}
# outputs.tf — return the whole resource and let the caller pick
output "queue" {
  value = aws_sqs_queue.main
}

output "dead_letter_queue" {
  value = aws_sqs_queue.dead_letter
}

Topic 2: Using a Module

module "work_queue" {
  source      = "./modules/queue-with-backoff"
  queue_name  = "work-queue"
  name_prefix = var.org_prefix
}

module "thread_queue" {
  source      = "./modules/queue-with-backoff"
  queue_name  = "thread-queue"
  name_prefix = var.org_prefix
}

output "work_queue_name" {
  value = module.work_queue.queue.name
}

Reference form for module outputs: module.<name>.<output_name>.

Two instances, one definition. Change message_retention_seconds inside the module and both queues update. Written flat, that would be four resource blocks and four edits — and at ten or a hundred instances, the difference is the whole argument for modules.


Topic 3: Modules Enforce Structure

Beyond deduplication, a module makes conventions unavoidable. Prefixing every queue name inside the module means every consumer gets consistent naming for free. Take an environment name as an input and names can never collide across environments.

This is the underrated benefit: a module is a place to put decisions so callers cannot get them wrong.


Topic 4: Remote Modules

# Registry — version constraints work here
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"
  name    = "prod-network"
  cidr    = "10.0.0.0/16"
}

# Git over HTTPS — pin with ?ref=
module "custom" {
  source = "git::https://git.example.internal/platform/modules.git//network?ref=v1.2.0"
}

# Git over SSH
module "custom_ssh" {
  source = "git@git.example.internal:platform/modules.git//network?ref=v1.2.0"
}

The // separates the repository from a subdirectory within it. Terraform clones remote modules during init into .terraform/modules/.

Pinning is not optional:

By default Terraform checks out the repository’s default branch. That means anyone’s merge silently rolls into your next apply — in production, without review, at whatever moment you happen to run Terraform next.

?ref= pins to a tag. A tag is a marker on a commit — think of it as a branch that never moves.

The version argument works only for registry modules, not Git sources. For Git, ?ref= is the mechanism. Getting this wrong is common: people add version to a Git-sourced module, Terraform ignores it, and they believe they are pinned when they are not.

terraform get              # download/update modules
terraform init -upgrade    # re-resolve module and provider versions

Topic 5: Sub-Modules and How Deep to Nest

Modules may call modules. A worked example: a cross-talk module opens bidirectional ingress and egress between two security groups — four rules. A cross-talk-3-way module instantiates it three times to connect three groups. Written flat that is twelve rule blocks to get exactly right; via modules the root configuration is a handful of lines.

Note the technique of passing a whole resource as an input rather than every field separately:

variable "security_group_1" {}    # receives the entire resource object
variable "security_group_2" {}
variable "port" { type = number }

The caution matters as much as the technique: do not nest more than one level deep without a strong reason. Too many sub-modules makes configuration confusing and overly complex. If the nesting does not make the code substantially easier to read, it is not the place for a module.


Topic 6: Module Practices

  • Single responsibility. One module per component — network, compute, database. Not one module for the whole stack.
  • Thin root module. Keep the root as composition and wiring; put substance in reusable modules.
  • Validate inputs. Fail early with a message that says how to fix it.
  • Semantic versioning. Tag vMAJOR.MINOR.PATCH and keep a changelog documenting breaking changes and migrations.
  • Document. A README with purpose, inputs, outputs, and a working example.
  • Version control, never copy-paste. A module copied between projects is two modules that will diverge.

Try it yourself: Point a module at a Git source without ?ref=, note the commit it resolved in .terraform/modules/, push a change to that repository, and run init -upgrade. Watch your configuration change without you editing anything. That is the failure mode pinning prevents.

Common mistake: Building a module before you have two callers. A module with one consumer is indirection with no reuse — you have made the code harder to read and gained nothing. Extract on the second occurrence, not in anticipation of one.