Skip to main content

Command Palette

Search for a command to run...

DAY #45: [DAY-20] AWS Terraform Modules

Let’s Learn How Custom Modules Simplify Complex Terraform Projects

Published
12 min read
DAY #45: [DAY-20] AWS Terraform Modules
A
Sharing real-time progress, roadblocks, and open source lessons. Hoping my notes help someone else feel less stuck. "Stop waiting to feel ready, stop waiting for the perfect moment, start messy, start scared and start before you feel qualified"

Introduction

Welcome back to Day 20 of our 30 Days of AWS Terraform journey.

Last day, we explored how Terraform provisioners help us go beyond simply declaring infrastructure and how they allow us to bridge that small but important gap between creating resources and actually preparing them for real use.

With only 10 days left in our 30-day challenge, it feels like the perfect moment to start thinking about structure, organization, and reusability.

Birthday countdown #2 - FlipAnim

Up until now, most of our Terraform work lived comfortably inside a single folder, with files like main.tf, variables.tf, and outputs.tf sitting together and doing their job. That approach is perfectly fine when we’re learning or experimenting. But as our projects start to look more like something we’d run in the real world, a question naturally begins to surface: how do we keep this clean, reusable, and manageable as it grows?

This is where today’s topic enters the picture. In this blog, we will not include the full EKS cluster setup, as going through the entire cluster creation here would be overwhelming. Instead, we’ll focus on understanding modules and how they make complex Terraform projects manageable, using simplified examples that illustrate the concepts clearly.

We’ll understand why modules exist, what problem they solve, and how they help us structure Terraform code in a way that makes sense for real-world use. Think of today as the moment where everything we’ve learned so far starts to come together in a more organized, intentional way.

By the end of this blog, we’ll have infrastructure that’s thoughtfully designed, reusable, and easy to extend.

Before we dive in, a little about myself:

Fact #20: I love asking “why?” more than I probably should. I find myself questioning things constantly, why a process works a certain way, why a decision was made, why one approach is chosen over another. Sometimes it can be a bit much for people around me. :P

What are modules?

Before we talk about custom modules, it’s important that we slow down for a moment and understand something more fundamental: what Terraform modules actually are.

At its core, a module in Terraform is simply a reusable piece of code. That’s it. It’s just a way of grouping Terraform configuration so we can use it again without rewriting the same logic over and over.

Now, a natural question comes up at this point: how is this different from a function in a programming language? And that’s a very good question.

Terraform isn’t a fully-fledged programming language. It’s a configuration language, which means it gives us a limited set of built-in functions, but it doesn’t allow us to define our own functions the way we would in languages like Python or Java. So when we want reuse, not just reuse of values, but reuse of entire infrastructure logic, Terraform gives us modules instead.

Modules let us take a chunk of infrastructure logic, package it together, and reuse it wherever we need. They help us encapsulate complexity. Instead of looking at dozens of resources every time we open our configuration, we can hide that complexity behind a module and interact with it using just a few well-defined inputs and outputs.

This becomes incredibly important as soon as we move beyond small demos. Think about something like a VPC. A real VPC isn’t just one resource, it usually includes subnets, route tables, gateways, and more. Writing all of that repeatedly would be tiring, error-prone, and hard to maintain. A module allows us to define that complexity once and then simply use it wherever needed.

And this is where our journey today really begins. Since we’re building a production-grade EKS setup, we’ll have multiple moving parts i.e. networking, IAM, authentication, secrets, and the cluster itself. Each of these can live in its own module, neatly organized and clearly separated. Instead of one massive Terraform file, we’ll have a structure that feels intentional and easy to reason about.

So as we move forward, keep this simple idea in mind: a Terraform module is just a reusable container for Terraform code.

Types of Modules

Now that we’re comfortable with what a module is, let’s take the next small step and talk about the different types of modules we’ll come across in Terraform. This is an important part of the story, because it helps us understand why we’re choosing to build our own custom modules in this project instead of relying entirely on what already exists.

When we explore the Terraform ecosystem, we’ll quickly notice that not all modules are created or maintained in the same way. Broadly speaking, Terraform modules fall into three categories: public modules, partner modules, and custom modules.

Let’s start with public modules. These are modules that are typically published and maintained by Terraform providers themselves i.e. providers like AWS, Google Cloud, Azure, and others. We’ll find them listed in the Terraform Registry, and they’re designed to solve common infrastructure problems in a standardized way. For example, there are public modules for VPCs, EKS clusters, IAM configurations, and many other services. These modules are widely used, well-tested, and a great way to get started quickly.

Closely related to public modules are partner modules. These are created and maintained by organizations that have an official partnership with HashiCorp. When we browse the Terraform Registry and look at the available filters, we’ll notice provider filters such as AWS, Azure, Google, Helm, and others and within those, we’ll also see modules labeled as partner modules. These are jointly maintained and follow certain standards agreed upon with HashiCorp. In simple terms, partner modules sit somewhere between community contributions and official provider-backed modules.

Both public and partner modules are incredibly useful, especially when we want to move fast or follow widely accepted patterns. But this naturally leads to a question that many beginners ask at this point: if these modules already exist, why would we ever need to create our own?

This is where custom modules come into the picture.

A custom module is a module that’s not managed by a provider or a partner. It’s created and maintained by us, our team, or our organization. Anyone can create a custom module. The process is straightforward: we write the Terraform code, place it in its own folder or repository, and optionally publish it to a GitHub repository. From there, we can tag releases, manage versions, and evolve the module over time.

One of the biggest advantages of custom modules is control. With public or partner modules, the design decisions are made by someone else. If something breaks, or if a change doesn’t align with our requirements, we’re dependent on the maintainers to fix or update it. With a custom module, we own the entire lifecycle. We decide what goes in, what stays out, and who is allowed to modify it.

Custom modules also allow us to lock down certain values intentionally. If there are configurations that shouldn’t be changed casually, especially in production, we can hardcode or tightly control them inside the module itself. That way, anyone using the module interacts only with the inputs we expose, and any deeper changes require deliberate updates to the module code.

This is exactly why most real-world organizations rely heavily on custom modules. They provide consistency, enforce standards, and reduce accidental changes, all while keeping Terraform code clean and reusable.

How Terraform organises modules?

Now that we understand why custom modules matter, let’s shift our focus to how Terraform actually organizes modules. This part is especially important, because once the structure becomes clear, everything else starts to feel far less mysterious.

Whenever we write Terraform code, no matter how small or simple, it always lives inside a module. Even if we don’t explicitly create one, Terraform still treats our configuration as a module. The folder where Terraform execution begins is known as the root module.

Think back to what we’ve been doing so far in this series. We usually created a directory, and inside it we had files like main.tf, variables.tf, outputs.tf, maybe a backend.tf or a .tfvars file. All of that together formed our Terraform project. Even though we never called it a module, that entire folder was actually the root module.

So when we say “root module,” we’re simply referring to the top-level Terraform configuration, the place where terraform init, terraform plan, and terraform apply are executed. This is where Terraform starts reading and understanding what we want to build.

Now, when we move into more realistic projects, like the one we’re building today, we don’t want everything to live in that single folder. Instead, we break things down into smaller, focused pieces. And this is where custom modules come in.

Inside our root module, we create subfolders. Each of these subfolders represents a custom module, and each one is responsible for a specific part of the infrastructure. For example, in this Day 20 project, our root module lives at the top level, and inside it we have a modules directory. Within that directory, we create separate folders for things like VPC, EKS, IAM, and Secrets Manager.

(add image)

Each of these module folders looks very familiar. Just like the root module, they contain files such as main.tf, variables.tf, and outputs.tf. The difference is not in the file names, but in their role. The root module orchestrates everything, while the custom modules focus on doing one thing well.

So, at a high level, the structure looks something like this: we have a root module that serves as the entry point, and inside it, multiple custom modules that handle networking, security, and the Kubernetes cluster itself. The root module pulls these modules together and passes values to them, while the modules return outputs back to the root module.

This relationship is very similar to calling a function with parameters. The root module provides the inputs, the module performs its work, and then it returns outputs that other parts of the configuration can use.

First Module: VPC

We’ll start small and focused, because this is how custom modules truly make sense.

Imagine we’re at Day 20, and this is our project layout:

The code/ directory is our root module.
The modules/vpc/ directory is our first custom module.

Step 1: Writing the VPC logic inside the custom module

Let’s go inside modules/vpc/main.tf.

This file contains the actual infrastructure logic. In our case, we start with the VPC itself:

Now pause here for a second and notice something important.

We are not hardcoding values like the CIDR block or the name.
Instead, we’re using variables such as var.vpc_cidr and var.name_prefix.

This tells us something very clearly:

This module expects someone else to provide these values.

That “someone else” is the root module.

Step 2: Declaring what the module expects (variables)

Since we’re using var.vpc_cidr, we must define it.
That happens inside modules/vpc/variables.tf

This file is not assigning values.
It’s simply saying:

“If you want to use this VPC module, these are the values you must give me.”

This separation is one of the biggest reasons modules feel clean and professional.

Step 3: Defining values in the root module

Now we move back to the root module, inside code/variables.tf (check the friendly project layout above)

Here’s where the values actually come from:

This is the moment where things start to click.

The same variable names exist in both places:

  • Declared in the module

  • Defined in the root module

Terraform wires them together for us.

Step 4: Calling the custom module from the root module

Now comes the most important part: using the module.

Inside code/main.tf, we write:

Let’s slow down:

  • module "vpc" → we are creating an instance of our custom module

  • source = "./modules/vpc" → this tells Terraform where the module lives

  • Everything below that → values being passed into the module

This is exactly like calling a function and passing arguments.

The module doesn’t care where the values came from.
The root module doesn’t care how the VPC is built internally.

Each has a single responsibility and that’s what makes this production-ready.

At this point, we’ve achieved something very important:

  • The VPC logic is encapsulated

  • The root module stays clean and readable

  • We can reuse this VPC module anywhere

  • We can control changes by modifying the module itself

Extending the VPC module: Subnets and Availability Zones

Let’s continue exactly where we left off.

Step 1: Deciding where logic should live

Before we write any code, we ask an important design question:

Should the VPC module decide which availability zones to use, or should the root module decide?

For production-grade projects, the answer is usually:
the root module decides, and the custom module consumes.

This keeps the module reusable across regions and environments.

Step 2: Fetching availability zones in the root module

Inside the root module (code/main.tf), we add a data source:

We don’t hardcode zone names. Instead, we ask AWS what’s available in the current region.

Now we take only the first three zones:

This locals block is just helping us keep things readable.
At this point, the root module knows which availability zones to use.

Step 3: Passing availability zones into the VPC module

We now pass these availability zones into the VPC module:

Notice something subtle but important.

azs, public_subnets, and private_subnets are not AWS resource arguments.
They are inputs to our custom module.

Terraform allows this because we define what these values mean inside the module.

Step 4: Declaring these variables inside the VPC module

Inside modules/vpc/variables.tf, we add:

Again, no values here, just expectations.

Step 5: Using these values inside the VPC module

Now comes the part where everything connects.

Inside modules/vpc/main.tf, we create public subnets:

Let’s slow this down.

  • count lets us create multiple subnets

  • Each subnet gets:

    • a CIDR from public_subnets

    • an AZ from azs

  • The index ties them together

This is a very common real-world pattern, and it works beautifully with modules.

Step 6: Why this is such an important moment

At this point, something fundamental has happened:

  • The root module controls decisions

  • The custom module executes logic

We’ve built a clean contract between the two.

If tomorrow we want to change regions, availability zones, or subnet layouts, we don’t touch the module logic, we only change inputs.

That’s the real power of custom modules.

Conclusion

And with that, we’ve completed Day 20 of the 30 Days of Terraform Challenge!

Today’s demo covered something truly important: the idea of custom modules. While it might sound technical at first, modules are really just a way to simplify our lives, they let us encapsulate complexity, reuse code, and maintain control over our infrastructure.

We also explored how modules interact with root modules, passed values using variables, and saw some hands-on code examples that demonstrate the concept. I intentionally did not include the full EKS cluster setup in this blog, because going through the entire cluster creation here would have been overwhelming. But don’t worry, there’s a video by Piyush Sachdeva that explains everything, including the complete EKS demo, with helpful troubleshooting steps along the way.

So take your time to digest these concepts, play around with the examples we went over, and feel free to experiment. Tomorrow, we’ll continue our journey and explore the next exciting step in the challenge.

Happy Terraforming, and see you in the next session!