Prerequisites
Before getting started, make sure you have the following:
- Basic knowledge of Terraform (HCL Syntax, resources, variables, remote state), the full prerequisite code is available in the GitHub repository
- Terraform installed on your machine (v0.12 or higher)
- Terragrunt installed, check the official installation guide
- An AWS account with suffficient permissions to create S3 buckets and DynamoDB tables
- AWS CLI configured with your credentials (aws configure)
- Visual Studio Codes as your code editor, with the HashiCorp Terraform extension for syntax highlighting and autocompletion.
Introduction
If you have been working with Terraform for a while, you have probably faced this situation: you have a working configuration for your dev environment, and now you need to deploy the same infrastructure to staging and prod. So you copy the folder, update a few values, including the remote state backend configuration, and repeat. It works, but something feels wrong.
That "something" is a violation of the DRY principle, don't repeat yourself. Every time you duplicate your backend configuration, you create a new opportunity for error and a new file to maintain.
In this article, we will explore how Terragrunt solves this problem by allowing you to define your remote state configuration once and reuse it across all your environments.
If you are new to terraform, I recommend exploring prerequisite code on GitHub before diving in.
The Problem: Repeat Remote State
When managing multiple environments with Terraform, most developers end up with a structure like this:
environments/
├── dev/
│ └── main.tf
├── staging/
│ └── main.tf
└── prod/
└── main.tf
And inside each main.tf, the same backend block appears with only one line changing:
# dev/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "dev/terraform.tfstate"
region = "us-west-2"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The same block is then copy-pasted into staging/main.tf and prod/main.tf, with only the key value changing (staging/terraform.tfstate, prod/terraform.tfsate). That's three files, three times the same configuration. And if you ever need to change the bucket name, the region, or encryption, you have to update every single file manually. This is exactly the kind of repetition that leads to human error and maintenance nightmares.
What is Terragrunt?
Terragrunt is a thin wrapper around Terraform, developed by Gruntwork, It doesn't replace Terraform, it enhances it by providing additional tools to keep your configurations DRY, manageable, and consistent across environments.
Think of it this way: Terraform is the engine, and Terragrunt is the intelligent framework built around it. You still write the same HCL code you know, but Terragrunt handless the repetitive parts for you.
With Terragrunt you can:
- Define your remote state configuration once and reuse it across all environments
- Automatically create your S3 bucket and DynamoDB table if they don't exist
- Deploy multiple environments with a single command
- Keep your codebase clean, readable, and easy to maintain The key concept we'll focus on in this article is the remote_state block — the feature that eliminates repeated backend configurations across environments.
The Solution: Centralized Remote State with Terragrunt
Instead of repeating the backend configuration in every environment, Terragrunt lets you define it once in a root terragrunt hcl file:
project/
├── terragrunt.hcl ← defined once here
├── dev/
│ └── terragrunt.hcl ← only what changes
├── staging/
│ └── terragrunt.hcl ← only what changes
└── prod/
└── terragrunt.hcl ← only what changes
The root terragrunt.hcl contains the full remote state configuration:
# terragrunt.hcl (root)
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-west-2"
dynamodb_table = "terraform-locks"
encrypt = true
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
Each environment file simply inherits from the root. Here is the dev example:
# dev/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
inputs = {
environment = "dev"
instance_type = "t2.micro"
}
The staging and prod files follow the exact same structure, only the environment and instance_type values change. That's it. Three environments, three small files, each containing only what is unique to that environment. The backend configuration lives in one place and is never repeated.
The full project structure with all environments is available in the GitHub repository.
Key Terragrunt Functions Explained
Two functions make all of this possible. Understanding them is the key to mastering Terragrunt.
- find_in_parent_folders() This funtcion automatically searches parent directories for the root terragrunt.hcl file. It allows each environment file to inherit the root configuration without hardcoding the path.
include "root" {
path = find_in_parent_folders() # finds ../../terragrunt.hcl automatically
}
No matter how deeply nested your environment folder is, Terragrunt will always find the root configuration.
- path_relative_to_include() This is the function that makes the state key dynamic. It returns the relative path of the current environment folder from the root.
key = "${path_relative_to_include()}/terraform.tfstate"
Concretely, this means:
|Environment folder | Generated state key |
| :---------------- | :------------------ |
| dev/ | dev/terraform.tfstate |
| staging/ | staging/terraform.tfstate |
| prod/ | prod/terraform.tfstate |
Each environment automatically gets its own isolated state file in S3, with zero manual configuration.
The generate Block
This block is often overlooked but extremely powerful. It tells Terragrunt to automatically generate a backend.tf file in each environment folder before running Terraform.
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
This means you never have to manually write a backend.tf file again. Terragrunt generates it for you, every time, with the correct values.
Deploying All Environments
once your configuration is in place, deploying all environments is as simple as running a single command from the root folder:
# Deploy all environments at once
terragrunt run-all apply
Terragrunt will automatically:
- Detect all terragrunt.hcl files in subdirectories
- Run terraform init for each environment
- Deploy each environment in the correct order
- Create the S3 bucket and DynamoDB table if they don't exist yet you can also target a specific environment:
# Deploy only dev
cd dev/
terragrunt apply
# Check outputs across all environments
terragrunt run-all output
# Destroy all environments
terragrunt run-all destroy
Compare this to the old approach where you had to navigate into each folder manually, run terraform init, then terraform apply, and repeat for every environment. With Terragrunt, that entire workflow collapses into one command.
Conclusion
Managing Terraform remote state across multiple environments doesn't have to be painful. With Terragrunt's remotestate block, find_in_parent_folders(), and path_relative_to_include(), you can define your backend configuration once and let Terragrunt handle the rest.
Let's recap what we covered:
- The problem: repeated backend configuration across environments violate DRY principle
- The solution: a single root terragrunt.hcl that centralizes the remote state configuration
- The magic functions:
find_in_parent_folders()andpath_relative_to_include()that make everything dynamic - The power of Terragrunt run-all apply: deploy all environments in one command. This is just the beginning of what Terragrunt can do. In the next article, Part 2, we will go deeper and explore how to split your Terraform dependency blocks. You will learn why putting your VPC, your security groups, and your EC2 instances in the same state file is a risk and how to fix it.
If you found this article helpful, feel free to share it and follow me for Part 2. The code for this article is available on my GitHub.
Top comments (0)