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.bz2 and .tbz2
  • .tar.gz and .tgz
  • .tar.xz and .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:

  1. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
  2. The .aws/credentials file 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:

OperatorMeaning
>= 2.5.0Version 2.5.0 or newer
<= 2.5.0Version 2.5.0 or older
~> 2.5.0Any version in the 2.5.x range (2.6.0 would not match)
>= 2.0.0, <= 2.5.0Any 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:

  1. Modules are just directories of .tf files with resources, variables, and outputs
  2. The source argument is required and tells Terraform where to find the module code
  3. There are many source options: local paths, Terraform Registry, GitHub, Bitbucket, generic Git, Mercurial, HTTP URLs, S3, and GCS
  4. Always pin versions in production to avoid surprises
  5. 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.

About

About BookGrill.net

BookGrill.net is a technology book review site for developers, engineers, and anyone who builds things with code. We cover books on software engineering, AI and machine learning, cybersecurity, systems design, and the culture of technology.

Know More