Composing Modules, the Terraform Registry, and Pinning Versions
In the previous article we wrote a module and called it multiple times. But the real power of modules comes from two places: wiring several modules into a larger layer, and reusing well-written modules others have made instead of building from scratch. This article does both: composing modules through the output/input interface, and pulling a module from the Terraform Registry with a tight version pin.
Goal
Know how to wire modules together (one's output becoming another's input), pull a module from the Registry with the correct source/version syntax, and understand why pinning module versions is mandatory for a stable project.
Composition: wiring modules through the interface
Modules have a clear input/output interface (Article 12), so wiring them together is as simple as connecting pipes: the output of module A goes into the input of module B. The root plays conductor, and the wiring lives at the root.
Example: one module creates a secure bucket (secure-bucket from the previous article), another module takes that bucket id and then puts an object into it:
module "data" {
source = "./modules/secure-bucket"
name_prefix = "tf-series-bai13-data-"
force_destroy = true
}
module "seed" {
source = "./modules/seed-object"
bucket_id = module.data.id # wire module.data -> module.seed
}
The line bucket_id = module.data.id is where composition happens. The id output of the data module becomes the bucket_id input of the seed module. This also creates an implicit dependency (Article 5) at the module level: Terraform knows it must finish building the data bucket before seed puts an object into it.
module "data" module "seed"
┌──────────────┐ ┌──────────────────┐
│ secure-bucket│ │ seed-object │
│ │ │ │
│ output id ──┼────────►│ input bucket_id │
└──────────────┘ │ -> aws_s3_object │
└──────────────────┘
root wires it: bucket_id = module.data.id
Apply, then check that the object indeed sits inside the other module's bucket:
$ terraform state list
module.data.aws_s3_bucket.this
module.seed.aws_s3_object.readme
This way of wiring lets you build infrastructure in layers: a network module supplies subnet ids, a compute module takes those subnet ids to place instances into — exactly the structure of the capstone article later on.
Terraform Registry: shared modules
You don't have to write every module yourself. The Terraform Registry holds thousands of public modules, among them the community-maintained terraform-aws-modules set, which is very high quality for AWS. Pulling a module from the Registry just needs a correctly formatted source and a version:
module "registry_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 5.0"
bucket_prefix = "tf-series-bai13-reg-"
force_destroy = true
}
A Registry module's source has the form <namespace>/<name>/<provider>. Unlike a local module (a ./... path), a Registry module requires a version. On init, Terraform downloads it:
$ terraform init
Initializing modules...
- seed in modules/seed-object
Downloading registry.terraform.io/terraform-aws-modules/s3-bucket/aws 5.13.0 for registry_bucket...
- registry_bucket in .terraform/modules/registry_bucket
- data in modules/secure-bucket
The constraint ~> 5.0 resolves to the latest 5.x release, here 5.13.0. The chosen version is recorded in .terraform/modules/modules.json:
$ cat .terraform/modules/modules.json # abbreviated
data -> (local)
registry_bucket -> 5.13.0
seed -> (local)
The local modules show (local) because they have no notion of a version — they're code in your project. Only modules from the Registry (or git with a tag) have a version to lock.
Why you must pin versions
This is an easily overlooked point that causes long-term incidents. Public modules change: authors fix bugs, add features, and sometimes change the structure in a breaking way at a major release. If you don't pin version, one day an init on another machine or in CI will pull down a newer release, and your infrastructure changes even though you didn't touch a line of code.
Pinning with ~> 5.0 allows any 5.x release (bug fixes, minor features) but blocks the jump to 6.0 (potentially breaking) — the same philosophy as pinning the provider in Article 2. When you want to bump a major, you change the constraint deliberately, read the changelog, test, and only then merge. The rule: every Registry module and every git source must pin a version; never let an external module run "the latest" automatically.
A module can also be sourced from git (source = "git::https://...//modules/x?ref=v1.2.0"), from S3, or from a company's private registry. With git, ?ref= plays the version-pinning role (pointing at a tag). Whatever the source, the version-locking principle doesn't change.
🧹 Cleanup
$ terraform destroy -auto-approve
Destroy complete! Resources: 7 destroyed.
Wrap-up
Compose modules by wiring one module's output into another's input right at the root (bucket_id = module.data.id), creating an implicit dependency at the module level and enabling layered infrastructure. The Terraform Registry provides shared modules with a source of the form <namespace>/<name>/<provider>, requiring a version; init downloads them and locks the chosen version in modules.json. Pinning versions (~> 5.0 for the Registry, ?ref=tag for git) is mandatory so the project doesn't change behind your back when external modules update.
The next article assembles everything from Part IV into a practical, useful module: a network module that builds a VPC with subnets, internet gateway and route table, then places an EC2 inside it — exactly the kind of foundational infrastructure module that nearly every project needs.