How modular are Terraform modules?

https://unsplash.com/photos/L0oJ4Dlfyuo

There comes a point, early in every infracoder’s journey, when they start to experiment with modules in Terraform. The power! The convenience! The reusability! Then, a little later in that journey, comes the point where they curse Mitchell Hashimoto to hell and back because those modules, well, they were not quite as modular as we had perhaps assumed.

The purpose of this post is to explore this definition found in the Terraform documentation:

The most important thing about Terraform modules is the thing that isn’t in that sentence. And the best way for me to explain that is with a series of examples.

OK I lied, the best way for me to explain it is with these dot points:

  • Modules allow chunks of Terraform configuration to be copied and incorporated into multiple places.
  • Modules isolate resource names, so if you have a virtual machine called “foo” in a module then it won’t clash with a virtual machine called “foo” in another module. (Contrast two virtual machines in separate .tf files within the one module.)
  • In addition, you cannot refer to resources in parent modules or submodules. Any data that wants to be passed between modules has to go through module outputs or input variables.
  • Modules do not isolate resources. All the resources from all the submodules are combined into a single dependency graph, executed in a single Terraform process, with a single plan-apply cycle.

But that was no fun so here are the worked examples:

This works

# main.tfresource "null_resource" "great-example" {
triggers = {
hello = "universe"
}
}
module "foo" {
source = "./foo"
bar = "quz"
}
# foo/main.tfvariable "bar" {}resource "null_resource" "great-example" {
triggers = {
hello = var.bar
}
}

As you can see from the following transcript, which you can reproduce at home fairly easily, there are two resources of the same type with the same name, within the same Terraform configuration, neatly separated by module boundaries:

pat@box $ terraform apply -auto-approve -input=false
module.foo.null_resource.great-example: Creating...
null_resource.great-example: Creating...
module.foo.null_resource.great-example: Creation complete after 0s [id=3748620768012689449]
null_resource.great-example: Creation complete after 0s [id=5829409579386208168]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.pat@box $ terraform state list
null_resource.great-example
module.foo.null_resource.great-example

It would be nothing but reasonable to assume, given the definition of “module” in the documentation, plus the behaviour you’ve seen so far, that Terraform modules provide a degree of isolation between modules.

So what happens when we want to do something a bit more advanced in the submodule, like customise the specific version of the provider we want to use?

This does not work

# main.tfterraform {
required_providers {
null = {
source = "hashicorp/null"
version = "< 3.0"
}
}
}
resource "null_resource" "great-example" {
triggers = {
hello = "universe"
}
}
module "foo" {
source = "./foo"
bar = "quz"
}
# foo/main.tfterraform {
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.0"
}
}
}
variable "bar" {}resource "null_resource" "great-example" {
triggers = {
hello = var.bar
}
}

The only change here is that the root module and the submodule each contain a terraform block specifying the provider version they require. This should be fine since it only applies within the module, right?

pat@box $ terraform apply -auto-approve -input=falseError: Provider requirements cannot be satisfied by locked dependenciesThe following required providers are not installed:- registry.terraform.io/hashicorp/null (~> 3.0, < 3.0.0)Please run "terraform init".pat@box $ terraform init -upgrade
Upgrading modules...
- foo in foo
Initializing the backend...Initializing provider plugins...
- Finding hashicorp/null versions matching "~> 3.0, < 3.0.0"...
Error: Failed to query available provider packagesCould not retrieve the list of available versions for provider hashicorp/null:
no available releases match the given constraints ~> 3.0, < 3.0.0

That was a bit wordy but the important part is this:

no available releases match the given constraints ~> 3.0, < 3.0.0

Clearly, no provider is going to simultaneously be at least version 3 whilst also being under version 3. But I didn’t ask for that, did I? I wanted two separate providers.

But here’s the trick with Terraform modules. You still only get one Terraform process, and that means you only get to load one version of each provider. You can have multiple configurations for that one version of the provider — for example for AWS this might mean one provider configuration for one account and another provider configuration for a different account — but you can only have one version of the provider loaded in the one Terraform process.

To illustrate the issue, imagine you find a really neat module on the Terraform Registry that does the thing you need doing. But it contains a provider requirement that differs from the rest of your project. The moment you incorporate this module, you need to upgrade the provider across the whole configuration, potentially resulting in incompatibility, subtle changes in behaviour and other headaches.

Photo by Alexandre Debiève on Unsplash

Closing thoughts, and breadcrumbs

This article has drawn upon my many decades of pain, suffering, trial and error to hopefully allow you to shortcut the process and skip straight to the effective, efficient use of Terraform’s module capabilities.

You now know why including provider blocks in published modules is not OK, why it’s important to specify minimum but not maximum provider versions in modules, and why the Terraform modules system is inherently limited in facilitating a self-service infrastructure platform.

It’s not that Terraform modules are bad, or that they were designed by a madman who set out to drive thousands of unwitting engineers totally insane. Rather, they represent a solution to a problem — packaging configuration for reuse — and Terraform’s position at the leading edge of infrastructure-as-code technology means nobody knew about the other problems until Terraform did it “wrong”. Use them where they help, avoid them where they don’t.

In terms of paths forward, you are now armed with the information you need to architect Terraform projects correctly, and to properly evaluate possible workarounds such as targeted apply, tfyolo(), Terragrunt and infracontainers.

In subsequent parts of this series (which can be enjoyed in isolation from this first instalment — see what I did there) I will look at more complex gotchas associated with Terraform modules, some details on workarounds, and a look at how other infrastructure-as-code tools address the module problem. If you have any questions of topics you’d like me to cover please get in touch with me at pat@p15.id.au.

"Wow, what a hack, that will never work" is usually a sign you are heading in the right direction

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store