Terraform Fundamentals

Modules: Reusable Infrastructure

18 min Lesson 9 of 30

Modules: Reusable Infrastructure

At Google and Amazon, no team provisions an S3 bucket or a VPC by writing raw resource blocks from scratch. They consume an internal module — a vetted, tested, company-standard package that encodes every security baseline and tagging requirement. Modules are the mechanism that scales Terraform from personal scripts to organisation-wide infrastructure platforms. When you reach the point where copy-pasting resource blocks between directories starts to feel wrong, modules are the answer.

What a Module Actually Is

A Terraform module is simply a directory of .tf files. Every Terraform project is already a module — the directory you run terraform init from is called the root module. When you call another directory (or a remote package) from your root, that is a child module. There is no special syntax to declare a module; the boundary is the filesystem path.

The three files you will almost always find in a well-structured module are:

  • main.tf — the resource definitions
  • variables.tf — input variable declarations
  • outputs.tf — values the module exposes to its caller

Optionally: versions.tf (required provider constraints) and README.md (human documentation, mandatory in the Terraform Registry).

Module composition: root calls child modules Root Module (main.tf) module "vpc" source = ./modules/vpc cidr = var.cidr env = var.env module "rds" source = ./modules/rds subnet_ids = module.vpc .private_subnets module "app" source = terraform-aws-modules /ecs/aws version = "~> 5.0" modules/vpc main / vars / outputs modules/rds main / vars / outputs Registry Module terraform-aws-modules/ecs output ref
Root module wiring three child modules — two local, one from the public Terraform Registry.

Writing a Local Module

Start with a minimal but realistic example: a reusable S3 bucket module that enforces versioning, server-side encryption, and public-access blocking — the baseline every production bucket should have.

# modules/s3-private/variables.tf variable "bucket_name" { description = "Globally unique bucket name" type = string } variable "tags" { description = "Tags applied to all resources" type = map(string) default = {} } # modules/s3-private/main.tf resource "aws_s3_bucket" "this" { bucket = var.bucket_name tags = var.tags } resource "aws_s3_bucket_versioning" "this" { bucket = aws_s3_bucket.this.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_server_side_encryption_configuration" "this" { bucket = aws_s3_bucket.this.id rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } bucket_key_enabled = true } } resource "aws_s3_bucket_public_access_block" "this" { bucket = aws_s3_bucket.this.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # modules/s3-private/outputs.tf output "bucket_id" { value = aws_s3_bucket.this.id } output "bucket_arn" { value = aws_s3_bucket.this.arn }

The module wraps four resources behind a two-variable interface. A consumer needs to know nothing about SSE or public-access blocks — those decisions are encoded once, inside the module.

Consuming a Module with a module Block

In the root module, wire the child in with a module block. The source argument tells Terraform where to find the module. The remaining arguments map to the child's variable declarations.

# root/main.tf module "artifacts_bucket" { source = "./modules/s3-private" bucket_name = "acme-ci-artifacts-${var.env}" tags = { env = var.env team = "platform" managed = "terraform" } } module "logs_bucket" { source = "./modules/s3-private" bucket_name = "acme-access-logs-${var.env}" tags = { env = var.env team = "security" managed = "terraform" } } # Reference a module output output "artifacts_arn" { value = module.artifacts_bucket.bucket_arn }

After adding a new module block, you must run terraform init before terraform plan. init downloads or copies module sources into the .terraform/modules/ cache. Forgetting this step is the single most common "module not found" error.

Never commit .terraform/ to Git. This directory contains downloaded providers and module source copies — it can be hundreds of megabytes and is fully reproducible by running terraform init. Add .terraform/ and *.tfstate to your .gitignore. The only state file that may live in Git is a *.tfstate used for local development in a personal sandbox — and even that is discouraged.

Module Sources and Versioning

The source argument accepts five kinds of sources, and the choice has real operational consequences:

  • Local path (./modules/vpc) — fastest inner loop, no network, no versioning. Use for modules that live in the same repository as the root configuration (monorepo style). Changes are picked up immediately on next init.
  • Terraform Registry (hashicorp/consul/aws) — the public registry at registry.terraform.io. Versioned via version. Community modules like terraform-aws-modules/vpc/aws are the industry-standard starting point. Always pin a version.
  • GitHub / GitLab (git::https://github.com/org/repo.git//subdir?ref=v1.2.3) — useful for private modules before you set up a private registry. Pin to a tag or commit SHA, never a branch name in production.
  • Private registry (Terraform Cloud, Spacelift, Env0) — the enterprise pattern. Same source syntax as the public registry but authenticated. Enables semantic versioning, automated testing pipelines, and access controls on module versions.
  • S3 / GCS bucket (s3::https://s3.amazonaws.com/bucket/modules/vpc.zip) — a lightweight private distribution mechanism without a full registry.
# Pinning a public Registry module — ALWAYS use version constraints module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.8" # allows 5.8.x, 5.9.x — blocks 6.0.0 name = "prod-vpc" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b", "us-east-1c"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] enable_nat_gateway = true single_nat_gateway = false # one NAT per AZ for HA enable_dns_hostnames = true tags = local.common_tags } # Pinning a private GitHub module to a tag module "eks_cluster" { source = "git::https://github.com/acme-platform/terraform-modules.git//eks?ref=v3.1.0" cluster_name = "prod-eks" cluster_version = "1.30" vpc_id = module.vpc.vpc_id subnet_ids = module.vpc.private_subnets }
Version constraint operators for modules: = 5.8.0 pins exactly (fragile — blocks security patches). ~> 5.8 (pessimistic constraint) allows patch and minor bumps within the same major, which is the recommended default for third-party modules. >= 5.0, < 6.0 is an explicit range useful when you know a breaking change is coming in 6.0. Run terraform get -update to pull the latest allowed version into the module cache.

Module Composition and Output Chaining

Modules become powerful when their outputs wire together. The pattern is: one module provides a resource, a second module consumes its output as an input variable, and Terraform builds the dependency graph automatically.

In the code block above, module.vpc.vpc_id and module.vpc.private_subnets reference outputs exported by the VPC module. Terraform sees this cross-module reference and guarantees the VPC is fully provisioned before it attempts to create the EKS cluster. You never need to manage depends_on for cross-module output references — the graph is implicit.

Keep modules thin at the interface. A module with 40 input variables is trying to do too much — it is a signal to split it. At HashiCorp and in mature IaC teams, a module typically solves one clearly scoped problem (a secure S3 bucket, a hardened EKS node group, an RDS cluster with IAM auth). Consumers pass a handful of variables, and the module makes all opinionated security and compliance decisions internally. Complexity is encapsulated, not exported.

Testing and Versioning Your Own Modules

When you maintain modules that other teams consume, you need a release discipline. The standard pattern mirrors how you would version a library:

  1. Keep modules in a dedicated repository (or a well-defined subdirectory of a monorepo). Do not mix module code with root-level configuration.
  2. Tag every release with a semver tag (v1.0.0, v1.1.0). Consumers pin to a tag, not a branch.
  3. Write automated tests with Terratest (Go-based) or Terraform's built-in terraform test (HCL-based, available since Terraform 1.6 / OpenTofu 1.7). Tests provision real infrastructure in an isolated AWS account, assert outputs, and destroy everything on completion.
  4. Run the test suite in CI on every pull request before merging and tagging.

This pipeline — PR → CI tests real infra → merge → tag → consumers update version pin — is the standard at Gruntwork, Hashicorp, and large platform engineering teams that publish internal module catalogs to thousands of engineers.