Writing Your First Module
Part III gave us enough of the language: variables, expressions, loops. But so far everything has lived in one flat directory, and standing up the same cluster of resources elsewhere meant copying. The module solves exactly that: it packages a group of resources into a named concept with a clear input/output interface, reusable. This article writes the first module and calls it twice to show the reuse value.
Goal
Understand what a module is, its standard structure, and be able to write a module with a clean input/output interface, then call it from the root with several different sets of inputs.
What a module is
The docs define it concisely: a module is "a container for multiple resources that are used together". It lets you describe infrastructure in terms of architectural concepts ("a secure bucket", "a web cluster") instead of listing out each individual resource.
Every Terraform configuration is already a module: the .tf files in the working directory form the root module. The root can call other child modules via the module block, passing one's output into another's input. The only difference between root and child is that the root is where you run commands, while the child gets called.
Standard structure
By convention a module has three files:
modules/secure-bucket/
variables.tf # input
main.tf # the resources
outputs.tf # output
Splitting into three files is a convention, not a requirement — Terraform reads every .tf file in the directory. But cleanly separating input / resources / output lets a user of the module look at variables.tf and outputs.tf and immediately understand the interface without reading the internals.
A good module, per the docs, "raises the level of abstraction by describing a new concept in your architecture". The accompanying warning: don't create a module that just thinly wraps a single resource — if you can't give the module a name that's independent of the main resource inside it, you're better off using that resource directly. The example below wraps four resources into one "secure bucket" concept, enough to earn being a module.
Writing the secure-bucket module
Input, in variables.tf:
variable "name_prefix" {
type = string
description = "Tiền tố tên bucket"
}
variable "versioning" {
type = bool
default = true
}
variable "force_destroy" {
type = bool
default = false
}
variable "tags" {
type = map(string)
default = {}
}
The internals, in main.tf — wrapping the bucket along with versioning, encryption, and public-access blocking (exactly the set of resources written by hand in Article 6, now turned into a reusable block):
resource "aws_s3_bucket" "this" {
bucket_prefix = var.name_prefix
force_destroy = var.force_destroy
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
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
}
Two conventions worth noticing. One is the local name this for the main resource — when a module has only one resource of each type, naming it this is the common approach, because the full address already carries the module prefix. Two is that the module does not declare a provider — it inherits the provider from the root that calls it, so the same module can run in whatever region the root decides.
Output, in outputs.tf — this is the interface the module returns to the outside:
output "id" {
value = aws_s3_bucket.this.id
}
output "arn" {
value = aws_s3_bucket.this.arn
}
Calling the module from the root
The root uses the module block, points source at the module's path, and passes inputs. Calling the same module twice with different inputs:
module "logs" {
source = "./modules/secure-bucket"
name_prefix = "tf-series-bai12-logs-"
force_destroy = true
tags = { Purpose = "logs" }
}
module "data" {
source = "./modules/secure-bucket"
name_prefix = "tf-series-bai12-data-"
versioning = false
force_destroy = true
tags = { Purpose = "data" }
}
output "logs_bucket" {
value = module.logs.id
}
output "data_bucket_arn" {
value = module.data.arn
}
A module's outputs are accessed via module.<name>.<output> — here module.logs.id and module.data.arn. The two calls create two independent clusters of buckets, four resources each, from the same piece of code.
Trying it out
init now also has to install the modules:
$ terraform init
Initializing modules...
- data in modules/secure-bucket
- logs in modules/secure-bucket
...
Terraform has been successfully initialized!
$ terraform apply -auto-approve
Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
Eight resources — four for each module call. The addresses in state carry the module prefix:
$ terraform state list
module.data.aws_s3_bucket.this
module.data.aws_s3_bucket_public_access_block.this
module.data.aws_s3_bucket_server_side_encryption_configuration.this
module.data.aws_s3_bucket_versioning.this
module.logs.aws_s3_bucket.this
module.logs.aws_s3_bucket_public_access_block.this
module.logs.aws_s3_bucket_server_side_encryption_configuration.this
module.logs.aws_s3_bucket_versioning.this
$ terraform output
data_bucket_arn = "arn:aws:s3:::tf-series-bai12-data-2026..."
logs_bucket = "tf-series-bai12-logs-2026..."
The prefixes module.data. and module.logs. are exactly the mechanism Terraform uses to keep the two clusters separate even though they're generated from the same definition. This is also why Article 7 mentioned that state mv is used when consolidating resources into a module: moving a resource from the root into a module means changing its address to carry the module. prefix.
🧹 Cleanup
$ terraform destroy -auto-approve
Destroy complete! Resources: 8 destroyed.
Wrap-up
A module packages a group of resources into a concept with a clear input/output interface: variables.tf is input, main.tf is the resources, outputs.tf is output. The root calls the child via the module block with source and inputs, and reads the results via module.<name>.<output>. Calling the same module multiple times with different inputs produces multiple independent clusters from one piece of code, each carrying the module.<name>. prefix in state. A module should wrap a concept that earns it, not thinly wrap a single resource.
The module in this article lives right inside the project (./modules/...). The next article goes further: combining several modules into a larger layer, pulling shared modules from the Terraform Registry, and pinning module versions so the project stays stable over time.