If you're familiar with Infrastructure as Code (IaC) tools, this post is for you. The goal here is to introduce you to Terragrunt
, a tool that enables you to follow the DRY (Don't Repeat Yourself) principle, making your Terraform code more maintainable and concise.
Terragrunt
is a flexible orchestration tool designed to scale Infrastructure as Code, making it easier to manage Terraform configurations across multiple environments.
Let's present the problem.
Before diving into Terragrunt
, let's first define the problem it solves. Consider the following Terraform project structure:
#./terraform/
.
├── envs
│ ├── dev
│ │ ├── backend.tf
│ │ └── main.tf
│ ├── prod
│ │ ├── backend.tf
│ │ └── main.tf
│ └── stage
│ ├── backend.tf
│ └── main.tf
└── modules
└── foundational
└── main.tf
In this scenario, we have a foundational module, with separate environments for dev, stage, and prod. As the complexity of the system grows, maintaining repeated backend configurations becomes more challenging.
Take, for example, the following backend configuration for the dev environment:
# ./terraform/envs/dev/backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "envs/dev/bakcend/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "my-lock-table"
}
}
This configuration is required for each environment (dev
, stage
, prod
), and you'll find yourself copying the same code across all of them. This approach isn't scalable and quickly becomes difficult to maintain.
Now, let's see how Terragrunt
simplifies this.
With Terragrunt
, you can move repetitive backend configurations into a single file and reuse them across all environments.
Here's how your updated directory structure looks:
# ./terraform/
.
├── envs
│ ├── dev
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └──terragrunt.hcl
│ ├── prod
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── terragrunt.hcl
│ ├── stage
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── terragrunt.hcl
└── terragrunt.hcl
The terragrunt.hcl
file uses the same HCL language as Terraform and centralizes the backend configuration. Instead of duplicating code, we now use Terragrunt’s path_relative_to_include()
function to dynamically set the backend key for each environment.
Here’s what that looks like:
#./terraform/envs/terragrunt.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "my-lock-table"
}
}
By centralizing this, you only need to update the root terragrunt.hcl
to apply changes across all environments.
You can include the root configuration in each child environment by referencing the root terragrunt.hcl
file like this:
#./terraform/env/stage/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
This drastically reduces duplication and keeps your backend configurations DRY.
One common challenge in managing Terraform configurations is dealing with repetitive provider blocks. For example, when you're configuring AWS provider roles, you often end up with the same block of code repeated across multiple modules:
# ./terraform/env/stage/main.tf
provider "aws" {
assume_role {
role_arn = "arn:aws:iam::0123456789:role/terragrunt"
}
}
To avoid copy-pasting this configuration in every module, you can introduce Terraform variables:
# ./terraform/env/stage/main.tf
variable "assume_role_arn" {
description = "Role to assume for AWS API calls"
}
provider "aws" {
assume_role {
role_arn = var.assume_role_arn
}
}
This approach works fine initially, but as your infrastructure grows, maintaining this configuration across many modules can become tedious. For instance, if you need to update the configuration (e.g., adding a session_name
parameter), you would have to modify every module where the provider is defined.
Terragrunt offers a solution to this problem by allowing you to centralize common Terraform configurations. Like with backend configurations, you can define the provider configuration once and reuse it across multiple modules. Using Terragrunt’s generate block, you can automate the creation of provider configurations for each environment.
Here’s how it works:
#./terraform/env/stage/terragrunt.hcl
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
assume_role {
role_arn = "arn:aws:iam::0123456789:role/terragrunt"
}
}
EOF
}
This generate block tells Terragrunt to create a provider.tf file in the working directory (where Terragrunt calls Terraform). The provider.tf file is generated with the necessary AWS provider configuration, meaning you no longer need to manually define this in every module.
When you run a Terragrunt command like terragrunt plan or terragrunt apply, it will generate the provider.tf file in the local .terragrunt-cache directory for each module
$ cd /terraform/env/stage/
$ terragrunt apply
$ find . -name "provider.tf"
.terragrunt-cache/some-unique-hash/provider.tf
This approach ensures that your provider configuration is consistent and automatically injected into all relevant modules, saving you time and effort while keeping your code DRY.
By centralizing provider configurations with Terragrunt, you reduce the risk of errors from manual updates and ensure that any changes to provider settings are automatically applied across all modules.
For installation instructions, please refer to the official documentation