Terraform Cert Guide Chapter 7 Part 1: Understanding Terraform Modules and Sources
Chapter 7 is where the book gets into one of the most practical parts of Terraform: modules. If you’ve been writing Terraform configs so far and copy-pasting blocks between projects, this chapter is basically the answer to “there has to be a better way.”
And there is. Modules are that better way.
So What Are Terraform Modules?
Think of a module as a reusable package of Terraform code. You write it once, and then any team or project can pull it in and use it without rewriting the same resources over and over.
At the most basic level, a Terraform module is just a directory containing .tf files with resources, variables (inputs), and outputs. That’s it. Every Terraform project you’ve written so far is technically a module already. It’s called the root module.
The interesting part starts when your root module calls other modules. Those are called child modules. And yes, child modules can call other child modules, creating a tree structure. But the book recommends keeping things flat, just one level of child modules. Deep nesting gets messy fast. Nobody wants to debug a module five layers deep.
You reference child modules like this:
module.<rootmodulename>.module.<childmodulename>
How Module Composition Works
The book uses an AWS VPC and subnet example to show how resources inside a module work together:
resource "aws_vpc" "vpc" {
cidr_block = var.cidr_block
}
resource "aws_subnet" "subnet" {
vpc_id = aws_vpc.vpc.id
availability_zone = "us-east-1a"
cidr_block = cidrsubnet(aws_vpc.vpc.cidr_block, 4, 1)
}
Then from your root module, you call these as separate modules and wire them together:
module "aws_network" {
source = "./modules/network"
cidr_block = "10.0.0.0/8"
}
module "aws_instance" {
source = "./modules/instance"
vpc_id = module.aws_network.vpc_id
subnet_ids = module.aws_network.subnet_ids
}
See how aws_instance uses outputs from aws_network? That’s the whole point. You build small, focused modules and then compose them together. Clean and reusable.
The Module Block Syntax
Defining a module is straightforward:
module "terraform-module" {
source = "terraform-aws-modules/vpc/aws"
version = "2.55.0"
}
The key arguments are:
- source (required): where to find the module code
- version (optional): which version to download
- input variables: whatever the module needs from you
You can also use for_each, count, providers, and depends_on as meta-arguments, but more on those in a bit.
Module Sources: Where Does the Code Live?
This is the biggest section of the chapter’s first half, and honestly, it’s the most useful for the certification exam. Terraform supports a bunch of different places to pull module code from.
Local Paths
The simplest option. Just point to a directory on your machine:
module "terraform-module" {
source = "../terraform-module"
}
Use ./ for the current directory or ../ for the parent directory. When you run terraform init, it won’t download anything because the code is already local. This is great for splitting up a large project into logical pieces. But it doesn’t help when your team needs to share modules across projects or pin specific versions.
Terraform Registry
This is the public module marketplace. Community members publish modules, and you can grab them with a simple reference following the <NAMESPACE>/<NAME>/<PROVIDER> pattern:
module "Terraform-consul" {
source = "hashicorp/consul/aws"
version = "0.8.0"
}
The Registry supports proper versioning, which is a big deal. You can pin to a specific version and not worry about upstream changes breaking your infra.
For private registries (like Terraform Cloud), you just add the hostname in front:
module "aws-vpc" {
source = "app.terraform.io/aws_vpc/vpc/aws"
version = "1.0.0"
}
Replace app.terraform.io with whatever your private registry hostname is. You might need credentials configured for this to work.
GitHub
Terraform is smart enough to recognize github.com URLs automatically. Clone over HTTPS:
module "terraform-module" {
source = "github.com/hashicorp/terraform-module"
}
Or over SSH:
module "terraform-module" {
source = "[email protected]:hashicorp/terraform-module.git"
}
GitHub supports a ref argument for selecting a specific version (branch, tag, or commit). For private repos, you need credentials or an SSH key.
Bitbucket
Same idea as GitHub. Terraform recognizes bitbucket.org URLs:
module "terraform-module" {
source = "bitbucket.org/hashicorp/terraform-module"
}
For public repos, Terraform uses the Bitbucket API to figure out whether it’s a Git or Mercurial repo. Private repos need authentication.
Generic Git Repository
For any Git repo that isn’t on GitHub or Bitbucket, prefix the URL with git:::
module "terraform-module" {
source = "git::https://example.com/terraform-module.git"
}
SSH works too:
module "terraform-module" {
source = "git::ssh://[email protected]/terraform-module.git"
}
Under the hood, Terraform runs git clone to grab the code. To pin a version, use the ref query parameter:
module "terraform-module" {
source = "git::https://example.com/terraform-module.git?ref=v1.2.0"
}
Generic Mercurial Repository
Similar pattern, but with an hg:: prefix:
module "terraform-module" {
source = "hg::http://example.com/terraform-module.hg"
}
Terraform runs hg clone for this one. Version pinning works the same way with ?ref=v1.2.0. Honestly, Mercurial is pretty rare these days, but the cert exam might test you on knowing it’s supported.
HTTP URLs
Terraform can pull modules from any HTTP or HTTPS URL that serves an archive file. It supports these formats:
.zip.tar.bz2and.tbz2.tar.gzand.tgz.tar.xzand.txz
module "terraform-module" {
source = "https://example.com/terraform-module.zip"
}
If the URL doesn’t have a clear file extension, you can force Terraform to interpret it with the archive parameter:
module "terraform-module" {
source = "https://example.com/terraform-module?archive=zip"
}
For authenticated endpoints, put your credentials in the .netrc file in your home directory.
S3 Bucket
You can host modules in AWS S3. Just prefix with s3:::
module "terraform-module" {
source = "s3::https://s3-eu-west-1.amazonaws.com/terraform-modules/tfmodules.zip"
}
Terraform uses the S3 API to access the bucket. It looks for AWS credentials in this order:
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYenvironment variables- The
.aws/credentialsfile in your home directory
GCS Bucket
Google Cloud Storage works the same way, with a gcs:: prefix:
module "terraform-module" {
source = "gcs::https://www.googleapis.com/storage/v1/modules/terraform-module.zip"
}
Authentication goes through the Google Cloud SDK. You point it to your service account key file.
Version Constraints
The version argument only works with module registries (Terraform Registry and private registries). For GitHub, Bitbucket, and generic Git repos, you pin versions through the ref parameter in the source URL itself.
If you don’t specify a version, Terraform downloads the latest. That’s risky for production because a new module version could break things. Always pin your versions.
Terraform supports these version constraint operators:
| Operator | Meaning |
|---|---|
>= 2.5.0 | Version 2.5.0 or newer |
<= 2.5.0 | Version 2.5.0 or older |
~> 2.5.0 | Any version in the 2.5.x range (2.6.0 would not match) |
>= 2.0.0, <= 2.5.0 | Any version between 2.0.0 and 2.5.0 inclusive |
The ~> operator is the one you’ll use most. It lets patch versions float while keeping the minor version locked. Good balance between stability and getting bug fixes.
Meta-Arguments for Modules
Beyond source and version, modules support a few meta-arguments:
count: Create multiple instances from a single module block. Works exactly like count on resources.
for_each: Same idea as count but iterates over a map or set. More flexible when you need distinct configurations for each instance.
providers: Pass specific provider configurations to a module. Useful when you have the same provider configured for multiple regions:
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "terraform_aws_west"
region = "us-west-1"
}
module "terraform-module" {
source = "./terraform-module"
providers = {
aws = aws.terraform_aws_west
}
}
Without the providers argument, the module would use the default (non-aliased) provider.
depends_on: Define explicit dependencies between modules or between a module and a resource. Use this sparingly because Terraform usually figures out dependencies on its own. But sometimes you need to force an order, like creating an IAM role before a VPC module runs:
resource "aws_iam_role" "terraform-role" {
name = "terraform-vpc-role"
}
module "terraform-module" {
source = "terraform-aws-modules/vpc/aws"
version = "~>2.5.0"
depends_on = [
aws_iam_role.terraform-role,
]
}
Tainting Resources in Modules
One last thing the book covers: you can taint individual resources inside a module to force Terraform to destroy and recreate them on the next apply:
terraform taint module.terraform_module.aws_instance.terraform_instance
You can only taint specific resources within a module, not the entire module itself.
Wrapping Up
This first half of Chapter 7 gives you the foundation for understanding Terraform modules. The key takeaways:
- Modules are just directories of .tf files with resources, variables, and outputs
- The source argument is required and tells Terraform where to find the module code
- There are many source options: local paths, Terraform Registry, GitHub, Bitbucket, generic Git, Mercurial, HTTP URLs, S3, and GCS
- Always pin versions in production to avoid surprises
- Keep module trees flat, one level of child modules is the recommended approach
In Part 2, we’ll get into actually building modules for Azure, AWS, and GCP, with real code examples.
Previous: Chapter 6: Terraform Workflows
Next: Chapter 7 Part 2: Building Real Modules
This is part of a series retelling “HashiCorp Infrastructure Automation Certification Guide” by Ravi Mishra (Packt, 2021). For the full text, grab the book - ISBN: 978-1-80056-597-5.