Jay Taylor's notes

back to listing index

How to manage multiple environments with Terraform using Terragrunt | by Yevgeniy Brikman | Gruntwork

[web search]
Original source (blog.gruntwork.io)
Tags: howto terraform cloud-infrastructure iaac infrastructure-as-code terragrunt state-management blog.gruntwork.io
Clipped on: 2023-03-07

Image (Asset 1/14) alt= region = "us-east-2"
}resource "aws_instance" "example" {
ami = "ami-0fb653ca2d3203ac1"
instance_type = "t2.micro"
tags = {
Name = "example-server"
}
}

To use this code with Terragrunt, you need to turn this code into a module that can be configured differently in different environments. To do that, add input variables for any values that differ between environments:

variable "instance_type" {
description = "The instance type to use"
type = string
}
variable "instance_name" {
description = "The name to use for the instance"
type = string
}

Update the code to use these variables:

resource "aws_instance" "example" {
ami = "ami-0fb653ca2d3203ac1"
instance_type = var.instance_type
tags = {
Name = var.instance_name
}
}

Finally, put the code in a folder with an appropriate name, such as modules/ec2-instance. Your folder structure should look something like this:

.
└── modules
└── ec2-instance
├── main.tf
├── outputs.tf
└── variables.tf

Now you’re ready to deploy your ec2-instance module across multiple live environments: development, staging, and production. The idea behind Terragrunt is that you define your environments using terragrunt.hcl files that specify what modules to deploy and what inputs to pass to those modules, and you run terragrunt commands instead of terraform commands (e.g., terragrunt apply and terragrunt destroy).

First, create a folder live/dev for the dev environment, put an ec2-instance folder within it, and create a terragrunt.hcl file within the ec2-instance folder. So the folder structure should look like this:

.
├── live
│ └── dev
│ └── ec2-instance
│ └── terragrunt.hcl
└── modules
└── ec2-instance
├── main.tf
├── outputs.tf
└── variables.tf

Put the following contents in live/dev/ec2-instance/terragrunt.hcl:

terraform {
source = "../../../modules/ec2-instance"
}
inputs = {
instance_type = "t2.micro"
instance_name = "example-server-dev"
}

Notice how Terragrunt uses the same language, HCL, as Terraform itself. When you run terragrunt apply in the live/dev/ec2-instance folder, Terragrunt will read the terragrunt.hcl file in that folder and do the following:

  1. Copy the ec2-instance module code from your modules repo into a scratch folder.
  2. Run terraform apply in that scratch folder, passing in the contents of inputs as input variables.

Give it a shot!

$ cd live/dev/ec2-instance
$ terragrunt apply
Terraform will perform the following actions:# aws_instance.example will be created
+ resource "aws_instance" "example" {
+ ami = "ami-0fb653ca2d3203ac1"
+ instance_type = "t2.micro"
(...)
}
Plan: 1 to add, 0 to change, 0 to destroy.Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:

Enter yes to deploy, and you’ll have an EC2 instance running in dev.

Next, configure the staging environment by creating stage/ec2-instance/terragrunt.hcl with the following contents:

terraform {
source = "../../../modules/ec2-instance"
}
inputs = {
instance_type = "t2.micro"
instance_name = "example-server-stage"
}

Notice how the only difference is that instance_name is set to example-server-stage. Run terragrunt apply to deploy the server in staging.

Finally, configure the production environment by creating prod/ec2-instance/terragrunt.hcl with the following contents:

terraform {
source = "../../../modules/ec2-instance"
}
inputs = {
instance_type = "m4.large"
instance_name = "example-server-prod"
}

Here, both instance_type and instance_name have been updated to values appropriate for production. Run terragrunt apply once more to deploy. Your folder structure should now look like this:

.
├── live
│ ├── dev
│ │ └── ec2-instance
│ │ └── terragrunt.hcl
│ ├── prod
│ │ └── ec2-instance
│ │ └── terragrunt.hcl
│ └── stage
│ └── ec2-instance
│ └── terragrunt.hcl
└── modules
└── ec2-instance
├── main.tf
├── outputs.tf
└── variables.tf

At this point, you have three environments, with one EC2 instance in each. Under the hood, you have exactly one copy of your Terraform code in modules/ec2-instance, plus a handful of terragrunt.hcl files to manage your live environments in the live folder.

Switching between environments

With Terragrunt, environments are defined in files and folders. So to see what is deployed in each environment, you can browse the file system in the live folder:

$ cd live
$ tree
.
├── dev
│ └── ec2-instance
│ └── terragrunt.hcl
├── prod
│ └── ec2-instance
│ └── terragrunt.hcl
└── stage
└── ec2-instance
└── terragrunt.hcl

From a glance, it’s clear that there are three environments based on the three top-level folders, dev, stage, and prod. To make changes in one of these environments, you go into its corresponding folder, and run terragrunt commands:

$ cd dev/ec2-instance
$ terragrunt apply

Using different configurations in each environment

The terragrunt.hcl files for each module contain inputs that define the variables to set specifically in that environment. For example, in the preceding examples, you already saw the inputs that were set in the terragrunt.hcl file in dev/ec2-instance:

inputs = {
instance_type = "t2.micro"
instance_name = "example-server-dev"
}

And the inputs set in the terragrunt.hcl file in prod/ec2-instance:

inputs = {
instance_type = "m4.large"
instance_name = "example-server-prod"
}

That’s one of the strengths of Terragrunt: the terragrunt.hcl files can be minimalist, containing primarily the input values that differ from environment to environment.

Using different backends in each environment

Terragrunt offers a way to configure the backend for all your Terraform modules in a standardized, centralized way that minimizes code duplication. Create a new terragrunt.hcl file at the root of your live folder, so your file layout should look like this:

.
├── dev
│ └── ec2-instance
│ └── terragrunt.hcl
├── prod
│ └── ec2-instance
│ └── terragrunt.hcl
├── stage
│ └── ec2-instance
│ └── terragrunt.hcl
└── terragrunt.hcl

Next, include this root terragrunt.hcl in each of the child terragrunt.hcl files by adding the following to dev/ec2-instance/terragrunt.hcl, stage/ec2-instance/terragrunt.hcl, etc:

# Automatically find the root terragrunt.hcl and inherit its
# configuration
include {
path = find_in_parent_folders()
}

OK, now fill in the root terragrunt.hcl with the following configuration:

locals {
# Parse the file path we're in to read the env name: e.g., env
# will be "dev" in the dev folder, "stage" in the stage folder,
# etc.
parsed = regex(".*/live/(?P<env>.*?)/.*", get_terragrunt_dir())
env = local.parsed.env
}
# Configure S3 as a backend
remote_state {
backend = "s3"
config = {
bucket = "example-bucket-${local.env}"
region = "us-east-2"
key = "${path_relative_to_include()}/terraform.tfstate"
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}

Since each child terragrunt.hcl uses include to pull in the configuration above, when you run terragrunt apply, Terragrunt will now do the following:

  • Parse the file path to figure out which environment you’re in. For example, if you ran apply in dev/ec2-instance, the env local will automatically be set to dev and if you ran it in stage/ec2-instance, the env local will automatically be set to stage.
  • Generate a backend.tf file to configure S3 as a backend, setting the bucket name to example-bucket-<env> (where env is the value of the env local) and setting the key to the relative path between the root terragrunt.hcl and the child terragrunt.hcl. For example, when running terragrunt apply in live/dev/ec2-instance, the key will be set to dev/ec2-instance/terraform.tfstate and when running in live/stage/ec2-instance, it’ll be set to stage/ec2-instance/terraform.tfstate.
  • After that, everything else behaves exactly as before: Terragrunt will copy ec2-instance module into a scratch folder and run terraform apply, passing in the variables set in inputs.

So with Terragrunt, you can configure your backend in just one place, and now every module will automatically (a) use a separate, isolated backend in each environment and (b) store state files using the same file layout as the modules themselves, making it easy to go from one to the other.

Using different versions in each environment

Terragrunt makes it easy to try out different versions of your code in different environments by setting the source URL to different values. To see this in action, try turning the modules folder into its own Git repo.

$ cd modules
$ git init

Next, commit the code in the modules folder:

$ git add .
$ git commit -m "Create ec2-instance module"

Now, create release v1.0.0 in this repo using a Git tag:

$ git tag -a "v1.0.0"

And finally, create a repo in GitHub, and push the code there:

$ git remote add origin <YOUR_GITHUB_URL>
$ git push --follow-tags

At this point, instead of a local file path in the source URL, you can update the child terragrunt.hcl files in all three environments (dev, stage, prod) to use a GitHub URL with a version number as follows:

terraform {
source = "<YOUR_GITHUB_URL>//ec2-instance?ref=v1.0.0"
}

Now, let’s say you made some changes to the ec2-instance module and released v2.0.0. You could try that version out just in dev by updating dev/ec2-instance/terragrunt.hcl as follows:

terraform {
source = "<YOUR_GITHUB_URL>//ec2-instance?ref=v2.0.0"
}

In the meantime, stage and prod will continue to run v1.0.0. If the testing in dev goes well, you can promote v2.0.0 to staging by updating stage/ec2-instance/terragrunt.hcl. And if testing in stage goes well, you can finally promote v2.0.0 to production by updating prod/ec2-instance/terragrunt.hcl.

Terragrunt also allows you to run different versions of Terraform and Terraform providers in different environments by using the generate block. For example, you can generate the required_providers and required_version settings, and set them to different versions in different environments. This allows you to carefully upgrade versions across your code, one environment or even one module at a time.

Working with multiple modules

Let’s say that you added a mysql module to your modules repo and deployed it in each environment in your live repo with new terragrunt.hcl files. The file structure will look like this:

.
├── dev
│ ├── ec2-instance
│ │ └── terragrunt.hcl
│ └── mysql
│ └── terragrunt.hcl
├── prod
│ ├── ec2-instance
│ │ └── terragrunt.hcl
│ └── mysql
│ └── terragrunt.hcl
├── stage
│ ├── ec2-instance
│ │ └── terragrunt.hcl
│ └── mysql
│ └── terragrunt.hcl
└── terragrunt.hcl

One question that comes up is, How do you share data between modules? For example, if the ec2-instance module needs the database address from the mysql module, and they are each deployed separately, how do you share that data?

With workspaces and branches, your primary option was to use terraform_remote_state. With Terragrunt, you could still use terraform_remote_state, but you also have access to an alternative: dependency blocks.

Open up dev/ec2-instance/terragrunt.hcl and add the following dependency block:

dependency "mysql" {
config_path = "../mysql"
}

This says that the ec2-instance module depends on the mysql module. You can then have the ec2-instance module read an output variable from the mysql module as follows:

inputs = {
instance_type = "t2.micro"
instance_name = "example-server-dev"
db_address = dependency.mysql.outputs.db_address
}

Now, when you run terragrunt apply, Terragrunt will first go into the ../mysql folder, run terragrunt output to read all of that module’s outputs, and then, when running terraform apply, it will pass the db_address output through as an input variable.

The advantage of dependency blocks is that your underlying Terraform modules can stay completely decoupled: e.g., the ec2-instance module in the modules repo doesn’t have to know anything about the mysql module. All the ec2-instance module does is expose a db_address input variable, which you can set in many different ways: e.g., in one place, you might use Terragrunt to set it using an output from a dependency block; in another place, you could read the value in from a config file; in yet another place, you might set it to a mock value (e.g., during automated testing). This makes your code far more flexible and reusable.

Moreover, Terragrunt supports a run-all command which you can use to work with multiple modules concurrently, while respecting the dependencies between them. For example, you could deploy all the modules in all of your environments in a single command as follows:

$ cd live
$ terragrunt run-all apply

When you run this command, Terragrunt will do the following:

  1. Find all the modules in the live folder.
  2. Discover dependencies between those modules: e.g., in each environment, the ec2-instance module has a dependency on the mysql module.
  3. Run apply on all the modules, using as much concurrency as possible, while respecting the dependencies: so in this case, it’ll run apply on all the mysql modules concurrently, and then, as each one completes, it’ll run apply on the corresponding ec2-instance module.

Advantages of Terragrunt

  • Navigating environments and understanding what’s deployed is easy: just browse the file system.
  • Configure environments differently by setting different inputs in different terragrunt.hcl files.
  • Configure separate backends for each environment to isolate environments.
  • Configure backends for multiple modules and environments with no code duplication.
  • Configure and propagate different versions across different environments.
  • Very little code duplication: far less than branches, making maintenance considerably easier.
  • Share data between modules using either terraform_remote_state or dependency blocks. The latter keeps your code more loosely coupled, flexible, and reusable.
  • Work with multiple modules concurrently using run-all.

Drawbacks of Terragrunt

  • Requires installing a new, separate tool, plus learning an extra layer of indirection/abstraction.
  • Not natively supported by Terraform Cloud and Terraform Enterprise (though there are some workarounds).
  • More code duplication than with workspaces, as each new environment adds new folders and terragrunt.hcl files to manage.

Conclusion

You’ve now seen three options for defining and managing environments in Terraform: workspaces, branches, and Terragrunt. Here’s a summary table that shows how they compare (more black squares = better):

In our experience, we found that the lack of support for isolation and versioning made workspaces completely unsuitable for production deployments with multiple environments. Branches supported isolation and versioning better, but introduced so much code duplication that maintenance became a total nightmare. As a result, we opted to use Terragrunt, which offered a happy medium: full support for isolation and versioning, with minimal code duplication, plus support for working with multiple modules concurrently as a nice bonus. Let us know what your experience has been in the comments!

Your entire infrastructure. Defined as code. In about a day. Gruntwork.io.

--