Terraform

What Are Terraform Modules and How to Use Them: Tutorial

terraform modules

Terraform makes it easier to grow your infrastructure and keep its configuration clean. But, as the infrastructure grows, a single directory becomes hard to manage.

That’s where Terraform modules come in. 

In this post, you will learn what Terraform modules are, how to use them, and what problems they solve.

  1. What is a Terraform module?
  2. Types of Terraform modules
  3. When should you use Terraform modules?
  4. How to use Terraform modules
  5. What problems do Terraform modules solve?
  6. Terraform modules best practices

TL;DR

A Terraform module is a reusable package of Terraform configuration that groups resources into a single, callable unit. You use a module block to call it, set behavior with input variables, and read values back through outputs. Your working directory is the root module, and anything called via module is a child module. Modules can come from local paths, Git, or registries, and you pin versions to roll out changes safely.

What is a Terraform module?

A Terraform module is a collection of standard configuration files in a dedicated directory. Terraform modules encapsulate groups of resources dedicated to a single task, reducing the code you need to write for similar infrastructure components.

Some say that Terraform modules are a way of extending your present Terraform configuration with existing parts of reusable code, reducing the amount of code you have to develop for similar infrastructure components. Others say that the Terraform module definition is a single or multiple .tf files stacked together in their own directory. Both are correct.

Module blocks can also be used to force compliance on other resources—to deploy databases with encrypted disks, for example. By hard-coding the encryption configuration and not exposing it through variables, you’re making sure that every time the module is used, the disks are going to be encrypted. 

A typical module can look like this:

.
├── main.tf
├── outputs.tf
├── README.md
└── variables.tf

As you can see, practically any Terraform configuration is already a module in itself. If you run Terraform in this directory, those configuration files would be considered a root module. It means that this configuration is the base of your operation, a core that you can expand further.

If you need more help learning about Terraform, check out our Get Started with Terraform tutorial.

Note: New versions of Terraform are placed under the BUSL license, but everything created before version 1.5.x stays open-source. OpenTofu is an open-source version of Terraform that expands on Terraform’s existing concepts and offerings. It is a viable alternative to HashiCorp’s Terraform, being forked from Terraform version 1.5.6.

What is the difference between resources and modules in Terraform?

A resource in Terraform describes a piece of infrastructure that is going to be created (e.g., a VPC, a subnet, an EC2 instance, etc), whereas a module is a collection of resources that are used together to achieve a reusable use case. Read more: Terraform Modules vs. Resources at Scale

Types of Terraform modules

Modules are used to create reusable components inside your infrastructure. There are primarily two types of modules depending on how they are written (root and child modules), and depending if they are published or not, we identify two different types as well (local and published).

Root module

The root module consists of all the resources defined in the .tf files in a Terraform configuration, meaning that all Terraform configurations have their own root module. 

Even if you are simply creating a main.tf that has just a locals block inside of it with a local variable, that is still considered a root module. 

This can be confusing, but keep in mind that every Terraform configuration can become a reusable module for other configurations. Every module can call other modules, and all of the modules called inside another module are considered child modules.

Child module

Calling a child module means to include all the resources defined in that module in the current configuration. This is done by using a module block inside your Terraform configuration:

module "webservers" {
   source = "../webservers"
}

You can call the same module as many times as you want and configure it to your liking.

Local module

A local module is a module that wasn’t published in any registry and when it is sourced, it is using the path to that particular module.

Published module

A published module refers to a module that has been pushed to a Terraform Registry, or even simply on a VCS and has a tag associated with it. When a published module is sourced, the URL of that module is used either from the registry or from the VCS itself.

When should you use Terraform modules?

The short answer to this question is always, but depending on your organization and where you are in the infrastructure as code journey, this may vary.

The best way to think about writing Terraform code is to keep reusability in mind. HCL, being a declarative language, can be very wordy, so repeating the same configuration over and over again will be cumbersome. So usually, if you can, start by defining modules from the beginning and then try to use them as much as possible for maximum reusability. 

If you are just starting with Terraform or IaC in general, you can omit using modules until you get a grasp of how Terraform works.

How to use Terraform modules

OK, now that you know what a Terraform module is, let’s take a look at a step-by-step process of creating them. So, let’s get started!

1. Create the module block

To use a Terraform module, you have to first declare that you wish to use it in your current configuration. To do this, use the module block and provide the appropriate variable values:

module "terraform_test_module" {
 source  = "spacelift.io/your-org/terraform-test-module/aws"
 version = "1.0.0"
 
 argument_1                     = var.test_1
 argument_2                     = var.test_2
 argument_3                     = var.test_3
}

As you’re adding the variables, there are a few arguments that you should keep an eye on: their source, version, and four meta-arguments.

Sources

Terraform modules can be stored either locally or remotely. The source argument will change depending on their location. For example, if the module you wish to call is stored in a directory named “terraform-test-module” located in the same place as your root module directory, your root configuration would look like this:

module "terraform_test_module" {
 source  = "./terraform-test-module"
[...]

Terraform modules can also be stored in so-called registries. Registries are places where you can find modules published by fellow Terraform users, or where you store the ones you have created — either privately, for your company/yourself, or publicly, for everyone to enjoy.

A Terraform module called from a registry might look like this:

module "terraform_test_module" {
  source  = "<namespace>/<name>/<provider>"
  version = "1.0.0"
  [...]
}

For example, here’s a public Terraform Registry module:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"
  [...]
 }

Please note that the URL scheme depends on the registry provider. For example, imagine that you wish to include a VPC module stored in the Spacelift registry in your configuration:

module "vpc" {
    source  = "spacelift.io/your-organization/vpc-module-name/aws"
    version = "1.0.0"
    [...]
}

As you’ve probably noticed, those examples are a bit different from one another. A big advantage of Spacelift’s registry is that it provides an example for each module. This means that you can check whether the source argument in your configuration is correct. Additionally, you can grab the entire example and use it as a template, filling in the blanks, and starting your work right away!

Versions

Versioning enables you to control what module changes should be introduced into your infrastructure. Why is it useful? It helps prevent damage to your infrastructure caused by unpredictable updates or faulty code. You probably know from experience that any update of software, packages, or applications on a production server can go wrong. This can also happen to Terraform modules, if they’re not kept under control.

The syntax for version constraints is simple. Simply put, a version is a string containing one or more conditions. 

The first part is an operator:

  • “=” (or no operator) means “Only one version—this specific version.”
  • “!=” translates to “Other versions are fine, except this one here”
  • “>, >=, <, <=” are used for comparisons. For example, “Use any version newer than 1.0.0, but older than 1.1.0”
  • “~>” is quite interesting. This operator allows only the rightmost part of the version number to increment. In other words, “~>2.6.0” would mean that you wish to use the newest patch version of the module (2.6.<anything>), but not the newer minor or major versions (2.7.0 or above).

The second part of the syntax is the version number. Although it isn’t absolutely required (except when using registries), it’s best to stick to the Semantic Versioning convention—it’s easy to understand and very transparent. One look at the version, and you know where you are.

As you might have already noticed, there is an exception to the “keep things versioned” rule—in two of the examples above, the version argument is defined. In one, it’s not. The reason for this is that Terraform considers modules loaded from the same source repository as being of the equal version to the caller. Local modules, by the way, need no version constraints whatsoever.

Installing and updating modules

After adding or changing a module source or version, initialize your working directory so Terraform can fetch the module code. For local filesystem modules, Terraform uses the code directly.

The terraform init command is the first command you should run after writing a new configuration or cloning one from version control. It scans your configuration for module blocks and retrieves the source code for any referenced modules. It also installs provider plugins and initializes the backend if configured.

Terraform stores downloaded modules under a generated .terraform/ directory in your working directory. Don’t commit this directory to version control.

If you run terraform init again later, Terraform will install any new modules you added since the last run, but it won’t change modules that are already installed. To update already-installed modules and upgrade provider plugins within their allowed version constraints, use -upgrade:

terraform init -upgrade

Alternatively, if you want to update only the modules without the other init steps, you can use terraform get -update, which checks already-downloaded modules for updates and downloads them if present.

Meta-Arguments

Meta-arguments are special arguments that change the behavior of Terraform when parsing the declared module. Currently, there are four of them in active use:

  • “Count” and “for_each” are used to create multiple instances of the same module or resource. More on this: Terraform Count vs. For_Each Meta-Argument: When to Use Them
  • “providers”— this meta-argument allows you to directly declare which provider the module should use for its operation. This comes in handy if you have two cloud service accounts and you want to create resources bound to the secondary. In order to achieve that, you need to properly design your Terraform module so that it takes the provider as an argument and passes it along, like this:
provider "aws" {
 alias  = "frankfurt"
 region = "eu-central-1"
}
 
module "example" {
[...]
 
 providers = {
   aws.nested_provider_alias = aws.frankfurt
 }
}
  • depends_on”—usually, Terraform handles implicit dependencies quite well, but sometimes they aren’t enough. If you need to declare that something should be created before something else, use this meta-argument to define an explicit boundary between resources or modules.

2. Declare module outputs

Sometimes you might need to use the values that are available in the already created resources. A Terraform module completely encapsulates those resources, and here’s how they can be accessed. 

First, declare in your Terraform module that the selected value should be available as an output:

output "random_string" {
 value       = aws_example_resource.example_device.random_string
 description = "A random string from an example resource on AWS."
}

Then, call this value like this:

resource "example_resource" "example" {
 [...]
 
 random_string = module.example.random_string
}

The module outputs are not passed on from the resources by default—so if you don’t declare an output, it won’t exist.

3. Recreate resources in a module (avoid terraform taint)

Sometimes you’ll need to force Terraform to recreate one of the resources managed by a module (for example, if the real resource is degraded or you need a clean reprovision). While older guides suggest terraform taint, the terraform taint command is deprecated—use terraform apply -replace=… instead.

Terraform doesn’t provide a “replace this whole module” switch, so you replace specific resources inside the module by their full resource address.

Step 1: Find the resource address (inside the module)

List resources in state that belong to your module:

terraform state list module.example

This outputs resource addresses like module.example.aws_instance.web that you can copy/paste into -replace.

Step 2: Review the plan (recommended)

terraform plan -replace=module.example.aws_example_resource.example_something

If the resource is in a nested module, include the full module path (e.g., module.parent.module.child.aws_instance.example).

Step 3: Apply the replacement

terraform apply -replace=module.example.aws_example_resource.example_something

Using -replace keeps the change visible in the plan/apply workflow (and avoids leaving “tainted” state behind for someone else to stumble into).

Note (OpenTofu): tofu taint is also deprecated, so use tofu apply -replace=… the same way. 

4. Test modules

Whether a blessing or a curse, Terraform’s code is still code, and should be properly tested. It is absolutely crucial. But when done manually, it can be quite a chore. Luckily, Spacelift, alongside other upgrades and extensions, provides automated module testing and you will absolutely love it. 

Terraform modules managed by the Spacelift registry get tested every time you push a new commit. They are applied, deleted, and you get all of the details on the run. If something fails along the way, the entire test fails, too. If everything goes smoothly, the test is successful. The entire process is automatic, but if you feel like playing around with it, you can even write your own tests, such as this one:

provider "aws" {
 region = "eu-central-1"
}
 
resource "random_string" "this" {
 length  = 16
 upper   = false
 number  = false
 special = false
}
 
resource "aws_vpc" "test" {
 cidr_block = "10.10.0.0/16"
}
 
module "test" {
 source = "../"
 
 cidr_blocks = {
   eu-central-1a = "10.10.0.0/24"
 }
 
 environment = "test-${random_string.this.result}"
 name        = "test-${random_string.this.result}"
 vpc_id      = aws_vpc.test.id
}

Customizable automated testing—what’s not to love here?

Read more: How to Rename a Terraform Module

What problems do Terraform modules solve?

Alright, now that you know how to use Terraform modules and what to keep an eye on when doing it, let’s see what problems Terraform modules solve.

1. Code repetition

As your Terraform infrastructure grows in scale, the code will need to do so as well. Copy-pasting an entire stack of code whenever you need more than a single instance of something isn’t scalable or efficient. It’s a waste of many things, time in particular.

2. Lack of code clarity

Copy-pasting isn’t clean either. Good code is readable, and great code documents itself. A modular approach addresses all those pesky details. A configuration of connecting modules dedicated to particular tasks is much easier to read and understand. 

3. Lack of compliance

When you create a Terraform module in compliance with appropriate standards and best practices, whenever you reuse it, it follows the same right pattern. No matter if it’s encryption, redundancy, or lifecycle policies—practices configured inside the module will be enforced, so that you won’t have to do it again personally.

4. Human error

When you copy-paste or create a group of resources from scratch, it’s easy to make a mistake, like rewriting or renaming something. To make sure that doesn’t happen, create a single Terraform module, test it, then use it in multiple places to check whether all of the elements are correct. It’s easier to check and test what you type into a single block than scroll, jumping from one place to another, and so on. 

As you can see, there are a lot of advantages to using Terraform modules, so long as they aren’t overused. Find the right balance and keep it. 

Terraform modules best practices

There are a couple of things you should consider when using Terraform modules:

  1. Each Terraform module should live in its own repository, and versioning should be leveraged.
  2. Minimal structure: main.tf, variables.tf, outputs.tf.
  3. Each Terraform module should have examples inside of them.
  4. Use input and output variables (outputs can be accessed with module.module_name.output_name).
  5. Used for multiple resources, a single resource module is usually a bad practice.
  6. Use defaults or optionals depending on the data type of your variables.
  7. Use dynamic blocks.
  8. Use ternary operators and take advantage of Terraform built-in functions.
  9. Test your modules.

Read more: 10 Best Practices for Managing Terraform Modules at Scale

Key points

The logic of Terraform modules may seem a bit complicated at first, but in the end, is a simple and efficient way of building your infrastructure quickly and reliably. If you want to step up your efforts, check out Spacelift and the additional features it offers, which extend and perfect the modular approach.

Spacelift provides everything you need to make your module easily maintainable and usable. There is CI/CD for multiple specified versions of Terraform, which is capable of testing your module on each commit. You get an autogenerated page describing your Module and its intricacies, so your users can explore them and gather required information at a glimpse. It’s also deeply integrated with all the features Stacks use which you know and love, like EnvironmentsPoliciesContexts, and Worker Pools. Create a trial account and check it out for free.

Cross-organization private module sharing and the above-mentioned module testing automation will help you get the most out of your configuration and make its development a lot smoother.

If you need more help with Terraform, I encourage you to check the following blog posts: How to Automate Terraform Deployments, and 12 Terraform Best Practices.

Enjoy writing your code and growing your infrastructure, and have a productive day!

Discover better way to manage Terraform

Spacelift helps manage Terraform state, build more complex workflows, supports policy as code, programmatic configuration, context sharing, drift detection, resource visualization and many more.

Start free trial

Frequently asked questions

  • What are the benefits of using modules in Terraform?

    Modules in Terraform provide reusability, consistency, and maintainability. They let you encapsulate infrastructure patterns, reducing code duplication and enabling standardized deployments across environments. Modules also simplify complex configurations by abstracting details, making infrastructure easier to manage, update, and share across teams.

  • When to write a Terraform module?

    Write a Terraform module when you need to reuse infrastructure patterns across projects, environments, or teams. It’s useful for abstracting complex setups, enforcing consistency, or managing frequently used components like VPCs, IAM roles, or EC2 instances. Create a module when similar code appears in multiple places or when isolation improves clarity.

  • How to run a specific module in Terraform?

    Terraform doesn’t support running a specific module in isolation. However, you can target resources within a module using the -target flag:

    terraform apply -target=module.module_name.resource_type.resource_name

    Use this sparingly, as it bypasses the full dependency graph and may lead to inconsistent state if overused. For testing modules separately, use them in isolated root configurations.

Terraform Modules Cheat Sheet

Grab our ultimate cheat sheet PDF to master
building reusable Terraform modules!

Share your data and download the cheat sheet