Tutorial: Module Defaults
Defining module defaults
Now that you have an understanding of what module defaults are, you can develop your own defaults module.
Begin by placing all default modules in a _module_defaults
directory. Beyond that, the structure of the code is up to you as a developer. Gruntwork recommends organizing the module defaults directory by creating subdirectories for each resource category (e.g., storage, networking) and naming the terragrunt files to represent the resource(s) they configure.
For example, if you define a defaults module for an AWS VPC where applications will be deployed, consider placing it in a directory named ‘networking’ and naming the file ‘vpc-app.hcl’. This structure results in _module_defaults/networking/vpc-app.hcl
.
Next, we’ll define the defaults module. It is important to note that a Terragrunt module follows the same structure as a standard terragrunt file. The defaults module defines default variable values and locals that can be reused in any environment.
The Terraform block
First, define a terraform
block. This block allows you to specify the source URL for the Terraform module you are using. Gruntwork recommends using a local
block to define the source URL. This approach lets you define the module’s source URL once in your defaults module and expose it as a variable to any module that references it. After defining the source URL, specify the desired module version.
terraform {
source = "${local.source_base_url}?ref=v0.104.19"
}
Locals
As with all Terragrunt modules, you can define a locals
block in a defaults module. Locals allow you to assign expressions to values, such as setting a common region name or dynamically reading values from configuration files. The example below demonstrates how to read account-specific information from another terragrunt file and use that information as a local.
locals {
source_base_url = "git::ssh://git@github.com/gruntwork-io/terraform-aws-service-catalog.git//modules/networking/vpc"
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
account_name = local.account_vars.locals.account_name
}
Specifying variable values
Finally, you can define inputs, where the module defaults pattern demonstrates its value by keeping your code DRY.
In the example block below, we set default values for the vpc_name
and num_nat_gateways
variables. A quick inspection of the vpc-app
module reveals that only one additional required variable — cidr_block
— remains unspecified. As a result, consumers of the defaults module need to provide just one input variable, achieving a 66% reduction in required inputs. You can build on this example by setting a default CIDR block while giving consumers the option to override it with a different value if needed.
inputs = {
// cidr_block is a required input but note we are excluding it!
vpc_name = local.account_name
num_nat_gateways = 1
}
Our example is relatively straightforward — the module only has three required variables but includes 79 additional optional variables. As you expand your usage to supply values for more of these optional variables, the amount of code you avoid writing increases significantly.
Wrapping up
Now that you understand how to develop a defaults module, let’s explore how you can use one to deploy resources to a specific environment.
Using module defaults
Infrastructure units
An infrastructure unit is the Gruntwork term for deploying an infrastructure-as-code module in a single environment. For example, deploying the vpc-app
module in your development AWS account constitutes a single infrastructure unit.
In this section, we’ll outline the approach Gruntwork uses for leveraging defaults modules as infrastructure units. When you purchase a DevOps Foundation, the generated repository containing your infrastructure-as-code is automatically configured to use this approach.
The Terraform block
Gruntwork recommends that infrastructure unit definitions include a orm
block and the orm
block defined in the underlying defaults module. By doing so, users can change module versions granularly across environments, avoiding a global change that impacts all environments simultaneously.
To ensure consistency with the defaults module, reference the source_base_url
value from the defaults module include
block (explained in the next section) and specify the desired release version after ref
. In the example below, we reference the base_source_url
from the defaults module and override the version to v0.47.0
.
terraform {
source = "${include.Module Defaults.locals.source_base_url}?ref=v0.104.19"
}
Including the module defaults module
Including a defaults module requires using an include
block. This approach allows you to use or override any default values and reference locals
from the defaults module.
For example, the include
block below references a defaults module located in _module_defaults/networking/vpc-app.hcl
. To enable referencing the locals
block within the defaults module, you must set expose = true
in the include
block.
include "Module Defaults" {
path = "${dirname(find_in_parent_folders())}/_module_defaults/networking/vpc-app.hcl"
# We want to reference the variables from the included config in this configuration, so we expose it.
expose = true
}
Note that we’re using the built-in Terragrunt function find_in_parent_folders()
to locate the root directory of the repository, then specifying the path to the Module Defaults module relative to that root.
Specifying dependencies
Dependencies are a Terragrunt concept that allow you to specify infrastructure units that must be applied before the current one. Dependencies work regardless of the source of the underlying module. This means that infrastructure units using a defaults module can reference dependencies in the same way as those that do not.
For example, suppose you need to add a dependency
block specifying that a KMS key defined in the parent directory of the VPC must exist before applying the current infrastructure unit.
dependency "kms" {
config_path = "../kms"
}
Using locals
Like Dependencies, Locals are a terragrunt concept that allow you to bind a name to an expression. Locals are particularly useful when using the module defaults pattern to set environment-specific values, such as CIDR blocks, resource name prefixes, and more.
Continuing with our vpc-app
example, we can specify the CIDR block using a local.
locals {
cidr_block = "10.0.0.0/16"
}
Overriding inputs
Inputs allow you to pass in values to the underlying module specified in the include
block. This is where the module defaults pattern shows it convenience and helps to keep your code clean and DRY.
Inputs defined in an inputs
block override the default values set in the underlying module. This setup enables module consumers to minimize the code they must provide while customizing the module as required.
Continuing with the vpc-app
example, imagine the terraform module requires 20 input variables, but only two of those variables need to be set differently across your environments.
Without module defaults, consumers would need to specify all 20 input variables each time they used the module, increasing the likelihood of errors or incorrect values.
With module defaults, you can predefine reasonable default values for all variables, allowing module consumers to override only the environment-specific values. This approach reduces the amount of code needed, promotes code reuse, and minimizes errors. For example, if your organization mandates that all VPCs include at least three private and public subnets, you can define those defaults in the module. Consumers can then override only the cidr_block
input variable for their specific environment.
In our vpc-app
example, we override the CIDR block for an environment-specific VPC by specifying the cidr_block
input variable in the inputs
block and providing the value defined in the locals
block.
inputs = {
cidr_block = local.cidr_block
}