Jay Taylor's notes
back to listing indexHow to manage multiple environments with Terraform using Terragrunt | by Yevgeniy Brikman | Gruntwork
[web search]}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:
- Copy the
ec2-instance
module code from yourmodules
repo into a scratch folder. - Run
terraform apply
in that scratch folder, passing in the contents ofinputs
as input variables.
Give it a shot!
$ cd live/dev/ec2-instance
$ terragrunt applyTerraform 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
indev/ec2-instance
, theenv
local will automatically be set todev
and if you ran it instage/ec2-instance
, theenv
local will automatically be set tostage
. - Generate a
backend.tf
file to configure S3 as a backend, setting thebucket
name toexample-bucket-<env>
(whereenv
is the value of theenv
local) and setting thekey
to the relative path between the rootterragrunt.hcl
and the childterragrunt.hcl
. For example, when runningterragrunt apply
inlive/dev/ec2-instance
, thekey
will be set todev/ec2-instance/terraform.tfstate
and when running inlive/stage/ec2-instance
, it’ll be set tostage/ec2-instance/terraform.tfstate
. - After that, everything else behaves exactly as before: Terragrunt will copy
ec2-instance
module into a scratch folder and runterraform apply
, passing in the variables set ininputs
.
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:
- Find all the modules in the
live
folder. - Discover dependencies between those modules: e.g., in each environment, the
ec2-instance
module has adependency
on themysql
module. - Run
apply
on all the modules, using as much concurrency as possible, while respecting the dependencies: so in this case, it’ll runapply
on all themysql
modules concurrently, and then, as each one completes, it’ll runapply
on the correspondingec2-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 differentterragrunt.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
ordependency
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.