Interested in learning more about env0?

Ned Bellavance

Founder, Ned in the Cloud

Introduction to Terraform Modules

Once you’ve started using HashiCorp Terraform to deploy your infrastructure, you’ll quickly realize there are common patterns and logical groupings of resources you might want to deploy across multiple configurations. Since Terraform defines infrastructure as code, it shouldn’t be surprising that there is a code-like way to express a group of resources, and that way is the humble Terraform module.

What are Terraform Modules?

At its simplest, a Terraform Module is a collection of resources defined by Terraform files (.tf or .tf.json) within the same directory. Now if that sounds awfully familiar, that’s because you’ve been using modules ever since you started using Terraform. If you have a configuration directory that looks like this:


That configuration represents the root module. What types of things would you find in your root module? Resources, data sources, input variables, and outputs. The root module can also contain child modules. And just like the root module, child modules can contain the same objects.


You might be wondering what are some of the most common use cases for a module. Well, a module really can be anything, but here are a few examples to get the gears turning:

  • Complete AWS VPC including subnets, route tables, an internet gateway, and more
  • Microsoft SQL Always On cluster in Azure including Network Security Groups
  • GCP Project with the required APIs enabled and permissions set

Once you have identified some common patterns in your deployments, it’s easy to spot good candidates to refactor into modules. But there’s more to it than just code reuse.

Why use Terraform Modules?

There are three primary reasons behind using a Terraform module, one of which we’ve already identified.

  • Package resources together in a reusable configuration
  • Share standardized configurations for common deployments with your team
  • Embrace Don’t Repeat Yourself (DRY) programming

We’ll go into more detail about each of these reasons later, as they will make more sense once you understand how to construct and use a module in your configuration.

Using Terraform Modules

Before you run off to create your own modules, let’s take a look at how child modules are used by a root module configuration and where modules can be found for use.

Module Sources

Terraform modules can be stored on the local file system, in a Terraform registry, or in a source control repository. When you first start creating modules, you’ll probably store them in a subdirectory of your root module as shown below.


Remember that Terraform will only process files that are in the current working directory, so any files you’ve placed in subdirectories will be ignored.

Storing your modules in a subdirectory is a great starting point. You can abstract away common components, create multiple instances of a child module, and copy a module to other configurations. However, storing your modules locally makes it more difficult to share and collaborate with other members of your team and makes it hard for them to discover what modules are available. For that, we can turn to a Terraform registry.

HashiCorp maintains a public Terraform registry that includes modules, providers, Sentinel policy libraries, and Terraform Cloud run tasks. The public registry is free to use and anyone can publish modules to it for others to discover and consume. Private versions of the registry are available for organizations that want to take advantage of the registry features, while still keeping their modules private. For instance, env0 includes a private registry for you to store your modules and make them available to only members of your organization. Terraform registries support module versioning, searching, and easy to consume documentation. Each module in the registry is linked to a source control repository that contains the module’s contents.

A third option for Terraform modules is to store them in a source control repository and access the module content directly rather than using an instance of the Terraform registry. While this does provide a remote, shared location for storing modules, it doesn’t natively support the versioning and discovery features common to Terraform registry implementations.

Module Usage

Adding a module to your configuration is as simple as specifying a `module` block with the source location of the module you’d like to use.

module "web_app" {
  source = "./modules/web_tier/"


After you add the module block, you will need to run `terraform init` to copy the module contents to your configuration. For modules with a remote source, Terraform will copy the contents of the module to the `.terraform/modules` subdirectory. If the module is stored locally, Terraform will simply make note of that in the `.terraform/modules/modules.json` file.

To supply information to the module, you will specify arguments that correspond to the input variables defined within the module.

module "web_app" {
  source = "./modules/web_tier/"
  name = "web-app-a"
  size = "medium"
  min_count = 2

The outputs defined in the module can be referenced through standard Terraform addressing syntax. For instance, if there is an output called `app_dns_fqdn` defined in the `web_tier` module, we can reference it in our root module with the following:

locals {
    web_app_address = module.web_app.app_dns_address

The objects defined in the module cannot be referenced directly from the root module, and the child module cannot reference objects defined in the root module. The input variables and output values defined by the child module are the only way for the root module to interact with the child module’s contents. This creates a contract between the root and child module similar to an API or function library in other programming languages.

Terraform Modules Diagram

By scoping object access this way, the root module doesn’t have to worry about the implementation details inside the child module. As long as the child module takes the agreed upon inputs and provides the desired outputs, it doesn’t matter how it accomplishes that goal. Child modules can continue to evolve and improve as long as they don’t break the contract with the root module. Of course, newer versions of the module may introduce breaking changes, and fortunately Terraform has a way of handling that.

Versioning Modules

Within the module block, you can specify a version argument along with a constraint value defining what version(s) of the child module the root module may use. The version argument only works with modules that are stored on a Terraform module registry, not from a local file system or git repository. For example, suppose we’d like to use version 3.19.0 of the AWS VPC module from the public registry.

module "primary_vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.19.0"

When you run `terraform init`, Terraform will look for version 3.19.0 of the module in the public registry and download it to the local `.terraform/modules` directory if it is found. You can also specify a range of versions to support using the constraints syntax, and Terraform will use the newest version of the module that matches the constraints.

Using version constraints is highly recommended and is one of the big benefits of using a Terraform registry as a source. By staying on a specific version of the module, you avoid running into breaking changes that may be introduced in newer versions. You can upgrade at your own pace once you’ve completed the necessary testing.

Creating Terraform Modules

As we mentioned earlier, you’ve been creating Terraform root modules since you started using Terraform. While crafting a well-written child module shares many traits with a root module, there are also some unique considerations and best practices to follow.

Module Structure

Your module will be composed of one or more Terraform configuration files. The most common layout will include the following files:

  • all input variables for the module
  • the required version of Terraform and the providers
  • the resources and data sources included in the module
  • all output values for the module

It’s also common to include a file that defines the purpose of the module, inputs and outputs, resources created, and some common examples of how the module might be used. If you take a look at modules that have been published to the public registry, you’ll see a host of other files and subdirectories, but for the moment we should focus on the core components of inputs, outputs, and resources.

As an example, let’s construct a module that creates an Azure virtual machine running Linux with a public IP address. That’s a pretty common deployment pattern and it involves at least three Terraform resources: 

  • azurerm_linux_virtual_machine
  • azurerm_network_interface
  • azurerm_public_ip

Those are the resources that make up the content for our module, but we can’t just put those resources in a file and call it good. Each of these resources needs values for its arguments, and we can get some of those values through input variables.

Input Variables

Just like a Terraform root module, the input variables you define in your module are how information is passed at runtime to the module. Generally, all input variables are placed in the file. Although that’s not a requirement, it makes the maintenance of modules easier and provides a quick way to find a complete list of inputs.

For our example, we can start by thinking broadly about what type of information we’ll need in our Azure VM module. At a minimum, we need to know what Azure resource group to use and which region the resources will be deployed in. Everything else can be hardcoded for the moment:

variable "resource_group_name" {
  type = string
  description = "Resource group name for all module resources."

variable "region" {
  type = string
  description = "Region for all module resources."

When we invoke our new Azure VM module in the root module, we can provide input for each of these variables.

module "my_vm" {
    source = "./modules/azure_vm"
    resource_group_name = "my-rg"
    region = "westus"

Terraform needs a value for each defined input variable, and because we didn’t specify a default value in the variable block, we have to supply one in the module arguments.

Output Values

The output values in a module are how information is conveyed from the child module back to the parent module. The data type for the output value can be any valid Terraform data structure. You can pass a whole resource, a custom object, or a simple string. Outputs are typically defined in the file.

For our Azure VM module, we probably want to get the public IP address assigned to the VM and pass it back to the parent module.

output "public_ip_address" {
  value = azurerm_public_ip.main.ip_address
  description = "Public IP address of the VM."

Although Terraform doesn’t require a description for each output, it’s helpful for others using the module to know the purpose behind an output.

Resource Configuration

Now we come to the meat of the module, the resources we want to deploy in our target environment. Each resource and its attributes are locally scoped to the child module. When supplying values for each resource argument, you can’t reference any objects that aren’t defined inside the module. For instance, you couldn’t reference a local value defined in the parent module. Any values we want to specify for our resources need to come from input variables, data sources, or other resources in the same module.

The resources for a module are typically defined in a file, although larger modules may break resources into several files for ease of readability. For our Azure VM module, we’ll place all three resources in the same file.

resource "azurerm_public_ip" "main" {
  name                = "MyVMPublicIp"
  resource_group_name = var.resource_group_name
  location            = var.region
  allocation_method   = "Static"

resource "azurerm_network_interface" "main" {
  name                = "myVMNic"
  location            = var.region
  resource_group_name = var.resource_group_name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = var.subnet_id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          =

resource "azurerm_linux_virtual_machine" "example" {
  name                = "myVM"
  resource_group_name = var.resource_group_name
  location            = var.region
  size                = "Standard_F2"
  admin_username      = "adminuser"
  network_interface_ids = [,

  admin_ssh_key {
    username   = "adminuser"
    public_key = file("~/.ssh/")

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "18.04-LTS"
    version   = "latest"

Many of the arguments have hardcoded values for now, but we can update our module to include additional input variables to provide flexibility for the user. We can also keep some values hard-coded to adhere to best practices in our organization. For instance, perhaps we don’t want anyone using static IP addresses for their virtual machines. We can leave the private_ip_address_allocation argument hard-coded to Dynamic to prevent a module consumer from changing it.

Benefits of Terraform Modules

We briefly went over some of the benefits of using Terraform modules earlier. Now that you’ve seen how a module can be constructed and used, it’s time to revisit those benefits in more detail.


Creating reusable components is a common practice in all programming languages. Every language has libraries, modules, or functions that allow you to abstract a task or resource into a reusable object that lives outside of your main codebase.

Terraform’s primary unit of reusability is the module. By developing and using a module, like the Azure VM one we started, you can reuse that module anywhere you need that same set of resources deployed. Input variables add flexibility to the module, while you can bake in sane defaults or hard-coded values to help ensure best practices.


Leveraging Terraform modules can be a force multiplier, especially as your usage scales out. Having a solid library of modules not only makes the creation of new configurations simpler, it also helps with the ongoing maintenance of existing configurations. For instance, let’s say you want to add a new security feature to all your Azure VM deployments. You can update a single module, and then roll that update out to all configurations using that module. Assuming you’re using a Terraform registry, that process can be fully automated and controlled using version constraints deployment pipelines.

Team Collaboration

As your use of Terraform expands to encompass a larger group, you may want to use modules to ensure that deployed infrastructure meets your internal standards and industry best practices. Many platform teams create a catalog of Terraform modules that can be used by the application teams they support.

The open nature of modules allows anyone to read the contents and make suggestions for improvement. The public Terraform registry provides a rich set of existing modules that are ready for use, or can be copied and altered based on your requirements. Storing your modules in a Terraform registry assists with discovery across teams and provides versioning of modules, so each team can upgrade to the latest when they’re ready.

Next Steps

We’ve just scratched the surface on the rich topic of Terraform modules.You may be wondering how to refactor existing code, what happens to state when a module is introduced, and how to test module updates before releasing them for consumption. We plan to cover each of those topics in future posts.

As you begin to build up a catalog of modules for internal use, you’re going to want to store them in a Terraform supported registry. While the public registry is an option, you may want to keep your module and the code that supports it private. If that’s the case, then you’re in luck! env0 includes a private registry for Terraform modules as well as Terraform providers. The private registry is available for all tiers of customers, including the free tier. Get started now by signing up for a free account!

Key Takeaways

Infrastructure as Code is first and foremost code, and a chief principle of writing good software is leveraging abstractions to make your code reusable, scalable, and consistent. Terraform modules are the abstraction provided by HashiCorp to take logical groupings of resources and package them together in a reusable object.

Terraform modules can be stored on a file system, in source control, or in a compliant Terraform registry. Using a registry has the benefits of nature versioning support and discoverability for your team and organization. By developing internal modules at your company, you can bake in sane defaults and industry best practices for reuse by infrastructure and applications teams.

You can start using modules today by perusing the public registry hosted by HashiCorp. And when you’re ready to start writing your own, you can leverage the private registry available on env0. Try env0 for free today!

No items found.
Manage IaC at scale, with confidence 

Use custom workflows to model any process

Visualize all IaC changes pre and post-deployment

Gain code-to-cloud visibility and governance

Improve developer experience and collaboration

Ned Bellavance

Share this post

Manage IaC with confidence 

Use custom workflows to model any process

Visualize all changes pre and post-deployment

Gain code-to-cloud visibility and governance

Improve developer experience and collaboration

Start Free Trial
See what env0 can do for you

env0 is the best way to deploy, scale, and manage your Terraform and other Infrastructure as Code tools.

Milo waving