The third time you copy the same bucket, versioning and public access settings into a new project, one copy will be different. Maybe someone forgot the public access block, or used an old tag format. Modules let you write that pattern once, give it a clear interface and reuse it everywhere.

This is part 4 of Terraform from Zero. You will extract the bucket from earlier parts into a module, call it from more than one place and learn how to version and share it.

What a module is

Every folder of .tf files is a module. The folder where you run terraform is the root module. When it uses a module block to include another folder, that folder is a child module.

A module works like a function:

  • Its variables are the parameters.
  • Its resources are the body.
  • Its outputs are the return values.

The caller cannot reach inside a module. It only sees what the module exposes as outputs, which is what makes modules safe to reuse.

A sensible project layout

text
infra/
  modules/
    private-bucket/
      main.tf
      variables.tf
      outputs.tf
      versions.tf
      README.md
  envs/
    dev/
      main.tf
      backend.tf
    prod/
      main.tf
      backend.tf

Each folder under envs/ is its own root module with its own state file. The shared logic lives once in modules/.

Write the module

modules/private-bucket/variables.tf:

hcl
variable "name_prefix" {
  description = "Prefix for the generated bucket name."
  type        = string
}

variable "versioning_enabled" {
  description = "Keep previous object versions."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags applied to every resource."
  type        = map(string)
  default     = {}
}

modules/private-bucket/main.tf:

hcl
resource "aws_s3_bucket" "this" {
  bucket_prefix = "${var.name_prefix}-"
  tags          = var.tags
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Suspended"
  }
}

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/private-bucket/outputs.tf:

hcl
output "name" {
  description = "Bucket name."
  value       = aws_s3_bucket.this.bucket
}

output "arn" {
  description = "Bucket ARN."
  value       = aws_s3_bucket.this.arn
}

modules/private-bucket/versions.tf:

hcl
terraform {
  required_version = ">= 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.0"
    }
  }
}

Notice what the module does not contain: no provider block and no backend. A reusable module declares which providers it needs with a minimum version, and the root module configures them. Naming the main resource this is a common convention when a module manages one main thing.

Call the module

envs/dev/main.tf:

hcl
provider "aws" {
  region = "eu-west-2"
}

module "notes_bucket" {
  source = "../../modules/private-bucket"

  name_prefix        = "field-notes-dev"
  versioning_enabled = false
  tags = {
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

output "notes_bucket_name" {
  value = module.notes_bucket.name
}

Run terraform init whenever you add or change a module source, then plan and apply as usual. Resources inside a module get addresses such as module.notes_bucket.aws_s3_bucket.this.

If you are converting the project from part 3, add a moved block so Terraform does not destroy and recreate the existing bucket:

hcl
moved {
  from = aws_s3_bucket.notes
  to   = module.notes_bucket.aws_s3_bucket.this
}

Repeat it for the versioning resource, and check that the plan shows moves rather than replacements before you apply.

Create many copies with for_each

Modules accept for_each, so one block can create a bucket per team:

hcl
module "team_buckets" {
  source   = "../../modules/private-bucket"
  for_each = toset(["analytics", "billing", "search"])

  name_prefix = "acme-${each.key}"
  tags        = { Team = each.key }
}

Each instance has a stable address keyed by name, for example module.team_buckets["billing"]. Prefer for_each over count for anything with an identity, because removing an item from the middle of a count list shifts every later index and forces replacements.

Version and share modules

Local paths are fine inside one repository. To share a module across repositories, publish it and pin a version.

From Git, pin a tag:

hcl
module "notes_bucket" {
  source = "git::https://github.com/acme/terraform-modules.git//private-bucket?ref=v1.2.0"
}

From a registry, use the version argument:

hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"
}

Tag releases with semantic versioning: bump the major version for any change that forces callers to edit their code or that replaces resources.

Use registry modules, but read them first

The public Terraform Registry has well-maintained community modules for VPCs, EKS clusters and more. They save time, but you are importing someone else's design into your infrastructure. Before adopting one, check:

  • Who maintains it and how recently it was released.
  • Which inputs it exposes, and whether its defaults match your security standards.
  • What a terraform plan actually creates. Large modules can create many resources from a few lines.

Always pin a version, and upgrade deliberately by reading the changelog.

Design rules that keep modules useful

  • One purpose per module. "Private bucket" is a good module; "everything for the billing service" usually is not.
  • Small, typed interface. Expose the inputs callers really need, with types, descriptions and safe defaults.
  • Secure by default. Bake in the settings everyone forgets, such as the public access block.
  • Avoid thin wrappers. A module that only renames one resource's arguments adds a layer without adding value.
  • Document it. A short README with an example call is enough; tools such as terraform-docs can generate the inputs table.

Key takeaways

  • Every folder of .tf files is a module; the root module calls child modules with module blocks.
  • Variables, resources and outputs make a module behave like a function with a clear interface.
  • Reusable modules declare required providers but never configure providers or backends.
  • Use moved blocks when refactoring existing resources into a module, and for_each for multiple instances.
  • Pin module versions from Git tags or the registry, and review third-party modules before trusting them.

Next in the series: part 5 runs all of this in CI with GitHub Actions, short-lived AWS credentials and terraform test.