How Modules Work

Creating a Terraform module involves defining input variables, output values, and one or more resource definitions that can be reused in other Terraform configurations.

A Terraform module should be specific to a resource it is creating. By maintaining a modular approach to module creation, you can more easily combine the modules in a configuration file that builds the infrastructure you want. If you opt to make modules too broad in scope, you are in danger of creating too many resources for what you require at run time. Of course, more modules mean more files and potentially more complexity in managing things, but on the other hand, having fewer lines of code to read, understand, and troubleshoot can pay dividends.

The file structure of a module

A Terraform module has the following files. They can exist in one single file as all Terraform does when it executes is it loads the content of each file and run as one big configuration. But to make life easier and follow good practices, creating a file dedicated to each function is cleaner and easier to work with as you can see below these files can become quite large for even relatively simple resources like an Azure Resource Group.

The files must exist in the same working directory, and Terraform will not traverse a folder structure.

main.tf where you define the resource the module creates

variables.tf where you define any input variables required at runtime

outputs.tf where you define any output variables that can be used at runtime

readme.md is not essential for runtime but is good practice to explain how your module works

This example shows the module code for an Azure Storage Account.

locals {
  tags = {
    app = var.prefix
    env = var.environment
  }
}

# Generate random text for a unique name for the resources
resource "random_id" "this" {
  byte_length = 4
}

resource "azurerm_storage_account" "this" {
  name                          = "st${var.prefix}${var.environment}${lower(random_id.this.hex)}"
  resource_group_name           = var.resource_group_name
  location                      = var.resource_group_location
  tags                          = local.tags
  account_tier                  = var.st_account_tier
  account_replication_type      = var.st_replication_type
  account_kind                  = var.st_account_kind
  min_tls_version               = "TLS1_2"
  enable_https_traffic_only     = true

  # The system-assigned managed identity for an Azure resource is used
  # to authenticate the resource when it needs to access other Azure 
  # services that support Azure Active Directory authentication.
  identity {
    type = "SystemAssigned"
  }
}
variable "resource_group_name" {
  type        = string
  description = "(Required) Name of the resource group where the workload will be managed"
}

variable "resource_group_location" {
  type        = string
  description = <<EOT
  (Required) Location of the resource group where the workload will be managed

  Options:
  - westeurope; west europe
  - eastus; east us
  - southeastasia; south east asia
  EOT

  validation {
    condition     = can(regex("^westeurope$|^eastus$|^southeastasia$|^west europe$|^east us$|^south east asia$", var.resource_group_location))
    error_message = "Err: location is not valid."
  }
}

variable "environment" {
  type        = string
  description = <<EOT
  (Required) Describe the environment type

  Options:
  - dev
  - test
  - prod
  EOT

  validation {
    condition     = can(regex("^dev$|^test$|^prod$", var.environment))
    error_message = "Err: Valid options are dev, test, or prod."
  }
}

variable "prefix" {
  description = "(Required) Name of the workload"
  type        = string
}

variable "st_account_tier" {
  type        = string
  description = <<EOT
  (Optional) Set the perforance tier
  
  Standard is recommened for most scenarios. But if high IOPS then premium is needed.
  
  Options:
  - Standard
  - Premium
  EOT

  default = "Standard"

  validation {
    condition     = can(regex("^Standard$|^Premium$", var.st_account_tier))
    error_message = "Err: Valid options are Standard, Premium."
  }
}

variable "st_replication_type" {
  type        = string
  description = <<EOT
  (Optional) Set the perforance tier
  
  LRS is 3x copies in a single availability zone. GRS offers 6x split between two regions. 
  
  Options:
  - LRS
  - GRS
  EOT

  default = "LRS"

  validation {
    condition     = can(regex("^LRS$|^GRS$", var.st_replication_type))
    error_message = "Err: Valid options are LRS, GRS."
  }
}

variable "st_account_kind" {
  type        = string
  description = <<EOT
  (Optional) Defines the Kind of account. 
  
  Options:
  - BlobStorage
  - BlockBlobStorage
  - FileStorage
  - StorageV2
EOT

  default = "StorageV2"

  validation {
    condition     = can(regex("^BlobStorage$|^BlockBlobStorage$|^FileStorage$|^BlobStorage$|^StorageV2$", var.st_account_kind))
    error_message = "Err: Valid options are BlobStorage, BlockBlobStorage, FileStorage, StorageV2"
  }
}
output "storage_account_name" {
  description = "The name of the storage account"
  value       = azurerm_storage_account.this.name
}

output "primary_blob_endpoint" {
  description = "The URI endpoint for the primaryu blob"
  value       = azurerm_storage_account.this.primary_blob_endpoint
}
# terraform-azurerm-storage_account
This code creates an Azure Storage Account resource using Terraform. The storage account will have a unique name generated by the random_id resource, prefixed with the value of the prefix variable passed to the module. The resource_group_name and resource_group_location variables are also passed to the module, specifying the resource group in which the storage account will be created, and the location of that resource group, respectively.

The storage account is created with the Standard account tier and LRS replication type, and TLS1.2 is enforced as the minimum version of TLS for secure connections. Public network access is disabled for the storage account. Additionally, a system-assigned managed identity is created for the storage account resource, which will be used to authenticate the resource when it needs to access other Azure services that support Azure Active Directory authentication.

Finally, a logging configuration is defined for the storage account's queues, with the logs being retained for a period of 10 days.
<!-- BEGIN_TF_DOCS -->
## Requirements

No requirements.

## Providers

| Name | Version |
|------|---------|
| <a name="provider_azurerm"></a> [azurerm](#provider\_azurerm) | 3.47.0 |
| <a name="provider_random"></a> [random](#provider\_random) | 3.4.3 |

## Modules

No modules.

## Resources

| Name | Type |
|------|------|
| [azurerm_storage_account.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/storage_account) | resource |
| [random_id.this](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource |

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_environment"></a> [environment](#input\_environment) | (Required) Describe the environment type<br><br>  Options:<br>  - dev<br>  - test<br>  - prod | `string` | n/a | yes |
| <a name="input_prefix"></a> [prefix](#input\_prefix) | (Required) Name of the workload | `string` | n/a | yes |
| <a name="input_resource_group_location"></a> [resource\_group\_location](#input\_resource\_group\_location) | (Required) Location of the resource group where the workload will be managed<br><br>  Options:<br>  - westeurope; west europe<br>  - eastus; east us<br>  - southeastasia; south east asia | `string` | n/a | yes |
| <a name="input_resource_group_name"></a> [resource\_group\_name](#input\_resource\_group\_name) | (Required) Name of the resource group where the workload will be managed | `string` | n/a | yes |
| <a name="input_st_account_kind"></a> [st\_account\_kind](#input\_st\_account\_kind) | (Optional) Defines the Kind of account. <br><br>  Options:<br>  - BlobStorage<br>  - BlockBlobStorage<br>  - FileStorage<br>  - StorageV2 | `string` | `"StorageV2"` | no |
| <a name="input_st_account_tier"></a> [st\_account\_tier](#input\_st\_account\_tier) | (Optional) Set the perforance tier<br><br>  Standard is recommened for most scenarios. But if high IOPS then premium is needed.<br><br>  Options:<br>  - Standard<br>  - Premium | `string` | `"Standard"` | no |
| <a name="input_st_replication_type"></a> [st\_replication\_type](#input\_st\_replication\_type) | (Optional) Set the perforance tier<br><br>  LRS is 3x copies in a single availability zone. GRS offers 6x split between two regions. <br><br>  Options:<br>  - LRS<br>  - GRS | `string` | `"LRS"` | no |

## Outputs

| Name | Description |
|------|-------------|
| <a name="output_primary_blob_endpoint"></a> [primary\_blob\_endpoint](#output\_primary\_blob\_endpoint) | THe URI endpoint for the primaryu blob |
| <a name="output_storage_account_name"></a> [storage\_account\_name](#output\_storage\_account\_name) | The name of the storage account |
<!-- END_TF_DOCS -->

What each file does

As mentioned earlier, each file serves a function, and the contents of each file should be primarily dedicated to that function. I say primarily, as sometimes there is a case for not creating a separate locals.tf file if you only need a couple of lines to cover your needs, but on the other hand, if you think it’s easier to manage code then separating locals and resource code into locals.tf and main.tf is up to you. There are few hard and fast rules which is why most modules that create the same resources can look different from each other. So long as the code works, does not produce security vulnerabilities, and is reusable by others, then the code is good.

main.tf - resource code

You define your infrastructure and supporting resources in this file. In the example code, a supporting resource block called resource "random_id" generates random string text to define part of the name of the storage account plus the resource block resource "azurerm_storage_account" that describes how the storage account is created.

To make the module as reusable as possible, avoid hard-coding parameter values here and instead use input variables. This allows the settings to be defined at runtime, depending on the need of the project. Where you do want to code parameter values would be things that should not be choice values, such as security-sensitive values. For example, setting the minimum TLS value to 1.2 could be a security requirement, and this is not something to be chosen.

variables.tf - input parameter values

Each parameter value defined as a variable in main.tf is described in this file. This file can appear complex, but in its basic form, it’s a key: value pair structure. The variable name is the key, and whatever data is provided at runtime is the value.

The complexity comes from the description and validation blocks. While these are not technical necessities, it is good practice to document the variable choices and control what values are allowed. For example, you are defining validation for the environment tag by limiting the accepted values to prod test dev, resulting in improved governance of your cloud resources.

outputs.tf - output parameter values

You would define outputs for two main reasons. Firstly, to enhance user experience, you can choose to expose, for example, an IP address or URL value of a resource when it is built, so the user does not need to hunt for the value in the portal. Secondly, these output values can be used downstream in your project as input variables when building additional resources. You may create a VM storing sensitive data in a key vault. In your project’s configuration code, you would have two modules defined. First, you create the key vault and output its resource ID, which is then used by the VM module.

readme.md - technical documentation

This file is nice to have but highly recommended as, by nature of the module code, it’s meant for sharing amongst multiple people, and documenting how the code functions and what is required to create a resource successfully is essential. If you want to save time, automation tools can help create a document based on the code itself, such as TerraDocs.

An example of how the modules are used

Let’s say your module library is stored in a local folder repository and the user executing the code has access to both directories. The tree view below shows the module and project directories plus the files within.

+---modules
|   +---azure_resource_group
|   |       main.tf
|   |       outputs.tf
|   |       readme.md
|   |       variables.tf
|   |
|   \---azure_storage_account
|           main.tf
|           outputs.tf
|           README.md
|           variables.tf
|
\---projects
    \---project00
            locals.tf
            providers.tf
            resource_group.tf
            storage_account.tf

Each module directory is configured as described earlier. In the projects directory you create a working directory called project00, where you define your resource requirements you create files named locals.tf, providers.tf, resource_group.tf, and storage_account.tf. Each of these files plays a specific function for the project build.

It is not the only way of doing this, but I prefer it for some use cases as I can maintain a single project working directory and have multiple Terraform files executed “as one”. So I can have a file called resource_group.tf one called storage_account.tf that work with each when created and updated, making for a unified infrastructure deployment. If you choose to separate your resources by folders, then each resource becomes distinct and, in my opinion, more challenging to manage. But again, rules are limited, and whichever method you choose is best for you.

locals {
  prefix      = "project00"
  location    = "west europe"
  environment = "dev"
}
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0.2"
    }
  }

  required_version = ">= 1.1.0"
}

provider "azurerm" {
  features {}
}
module "resource_group" {
  source  = "../../modules/azure_resource_group/"
  # insert required variables here
  prefix                  = local.prefix
  resource_group_location = local.location
  environment             = local.environment
}

output "resource_group_name" {
  description = "The name of the resource group"
  value       = module.resource_group.resource_group_name
}

output "resource_group_location" {
  description = "The location of the resource group"
  value       = module.resource_group.resource_group_location
}
module "storage_account" {
  source  = "../../modules/azure_storage_account/"
  # insert required variables here
  prefix                  = local.prefix
  resource_group_name     = module.resource_group.resource_group_name
  resource_group_location = module.resource_group.resource_group_location
  environment             = local.environment
  st_account_tier         = "Standard"
  st_replication_type     = "LRS"
  st_account_kind         = "StorageV2"
}

output "storage_account_name" {
  description = "The name of the storage account"
  value       = module.storage_account.storage_account_name
}

output "primary_blob_endpoint" {
  description = "THe URI endpoint for the primaryu blob"
  value       = module.storage_account.primary_blob_endpoint
}

locals.tf common values

A handy way of defining values used amongst all the resources. Often used to define the tag values or names of resources as you write them once here and then in each resource you refer to this file such as local.prefix.

providers.tf common values

You need to inform Terraform what providers you want to use to execute the files. In this example we’re uisng Azure. This is also where you can define any authentication paramaters, but this example is relying on local environment variables so nothing is needed in this file, this includes the target Azure subscription.

resource:group.tf describes the resource group

A simple resource that requires no direct editing as it only needs the values from the locals.tf file as input variables. But it produces outputs that are handy for remaining resources to call upon when they’re created, as in Azure, every resource requires a resource group. Coding the module for each resource to take the output of the resource group created in the same project working directory means you never need to define it manually at runtime. The essence of automation!

storage_accountf.tf describes the storage account

A relatively complex resource when you look at the module code, but here at run time you are required to provide just three input variables yourself, all others are taken by locals.tf or as outputs from the resource_group.tf code such as resource_group_name = module.resource_group.resource_group_name

OBS! This code does not go into authentication, as that’s not the documents goal

OBS! This code does not go into state file storage, as that’s not the documents goal

Build the resources

To execute the code, you navigate to the project working directory project00and execute the standard Terraform commands

terraform init

terraform plan

terraform apply

Resources

The Terraform code can be found in the grinntec showcase repository

https://github.com/grinntec/ShowcaseHub/tree/main/exampleTerraform%20Azure%2001

Last modified July 21, 2024: update (e2ae86c)