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
infra/
modules/
private-bucket/
main.tf
variables.tf
outputs.tf
versions.tf
README.md
envs/
dev/
main.tf
backend.tf
prod/
main.tf
backend.tfEach 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:
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:
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:
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:
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:
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:
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:
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:
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:
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 planactually 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
.tffiles is a module; the root module calls child modules withmoduleblocks. - 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
movedblocks when refactoring existing resources into a module, andfor_eachfor 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.

Ansible for Beginners: Automate an Ubuntu Server with Your First Playbook4 min read
Terraform and Ansible Together: Provision with One, Configure with the Other4 min read
Vercel Fluid Compute Explained: Concurrency, Active CPU Pricing and waitUntil4 min read