This is part 3 of the How to manage multiple environments with Terraform blog post series. In the second part of the series, you saw how to use branches to manage multiple environments with Terraform. In this post, I’ll show you how to manage multiple environments with Terraform using an open source tool called Terragrunt:
- Setting up environments using Terragrunt
- Switching between environments
- Using different configurations in each environment
- Using different backends in each environment
- Using different versions in each environment
- Working with multiple modules
- Advantages of Terragrunt
- Drawbacks of Terragrunt
- Conclusion
Setting up environments using Terragrunt
Let’s start again with the simple Terraform code to deploy a single EC2 instance as you saw in the workspaces example in part 1 of this series:
provider "aws" {
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:
- Copy the
ec2-instancemodule code from yourmodulesrepo into a scratch folder. - Run
terraform applyin that scratch folder, passing in the contents ofinputsas 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.*?)/.*", 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
applyindev/ec2-instance, theenvlocal will automatically be set todevand if you ran it instage/ec2-instance, theenvlocal will automatically be set tostage. - Generate a
backend.tffile to configure S3 as a backend, setting thebucketname toexample-bucket-<env>(whereenvis the value of theenvlocal) and setting thekeyto the relative path between the rootterragrunt.hcland the childterragrunt.hcl. For example, when runningterragrunt applyinlive/dev/ec2-instance, thekeywill be set todev/ec2-instance/terraform.tfstateand 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-instancemodule 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 = "//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 = "//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
livefolder. - Discover dependencies between those modules: e.g., in each environment, the
ec2-instancemodule has adependencyon themysqlmodule. - Run
applyon all the modules, using as much concurrency as possible, while respecting the dependencies: so in this case, it’ll runapplyon all themysqlmodules concurrently, and then, as each one completes, it’ll runapplyon the correspondingec2-instancemodule.
Advantages of Terragrunt
- Navigating environments and understanding what’s deployed is easy: just browse the file system.
- Configure environments differently by setting different
inputsin differentterragrunt.hclfiles. - 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_stateordependencyblocks. 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.hclfiles 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.



- No-nonsense DevOps insights
- Expert guidance
- Latest trends on IaC, automation, and DevOps
- Real-world best practices



