Multi-Cloud, Multi-Environment Terraform Architecture: Terragrunt and Real-World Strategies

Palak BhawsarPalak Bhawsar
9 min read

In my last article, I shared a folder structure to organize Terraform code for multi-cloud and multi-env setups. It keeps things clean and scalable. But there is still a lot of repetition like writing provider.tf, backend.tf, and variables again and again for each environment and cloud. As the project grows, it gets hard to manage. So in this article, I will show how Terragrunt helps remove that duplication and makes the setup simpler and easier to maintain.

In this article, I will take you beyond just the folder layout, demonstrating how to create real resources across multiple environments while keeping the code DRY (Don’t Repeat Yourself). To keep things cost-effective, I will deploy minimal resources in both Azure and AWS.

Along the way, I will cover strategies for:

  • Reducing code duplication and complexity using tools like Terragrunt to keep your Terraform code clean, maintainable, and DRY (Don’t Repeat Yourself)

  • Creating isolated environments with separate Terraform state files for each environment (dev, test, prod) and each cloud provider (AWS and Azure)

  • Using a single Git branch to safely manage multiple environments without requiring separate branches or repositories

⚠️ Note: I will cover the CI/CD setup for this project in the next article, as this post already covers a lot and I want to keep it focused and easy to follow.

Prerequisite:

  • Terraform installed

  • TFlint installed

  • Terragrunt installed to manage Terraform configurations across environments

  • AWS CLI installed and configured (aws configure)

  • Azure CLI installed and authenticated (az login)

  • Basic knowledge of Terraform syntax and module usage

  • Familiarity with AWS services and Azure services

Code:

1. Strategy for this Project

Instead of creating separate branches for each environment which can cause code duplication, merge conflicts, and configuration drift. I will keep all environment-specific configurations in one branch. Each environment lives in its own folder (envs/dev, envs/test, envs/prod) with isolated backend and provider settings. This ensures the Terraform state for each environment is separate and protected.

Why this approach?

  • Single Source of Truth: Infrastructure code remains consistent across environments, reducing errors and drift.

  • Simplified Collaboration: Teams can work on features and fixes without juggling multiple branches.

  • Safe Deployments: Changes are first applied and tested in dev, then promoted to test and finally prod, minimizing risks.

  • Easier Automation: CI/CD pipelines can be designed to deploy changes per environment folder, making automation straightforward and reliable.

I know you might be wondering:

How to Work with Single Branch and Deploy Changes Only to Dev?

Using a single branch for all your environments might raise the question: "What if I want to apply changes only to dev without affecting test or prod?"

Here’s how that works:

  • All your environment-specific configurations live in separate folders:
    envs/dev/, envs/test/, and envs/prod/. Each folder has its own Terraform state and variables.

  • When you want to make a change just for dev, you edit the files inside the envs/dev/ folder (like terraform.tfvars or terragrunt.hcl), or the shared modules those envs use if applicable.

  • Running terraform apply (or Terragrunt commands) only in the dev folder will apply changes to the dev environment’s infrastructure and state, leaving test and prod untouched.

  • Changes for test and prod environments can be promoted later by running the apply commands in their respective folders only after the dev changes have been verified.

  • Create CI/CD pipelines and deployment scripts to target specific environment folders

Why I Don’t Use Terraform Workspaces?

Terraform workspaces let you keep different environment states (like dev, test, prod) in the same folder and switch between them using commands. This sounds useful, but for this project, I don’t use them because:

  • Clear Separation by Folder
    I keep each environment in its own folder. This way, each environment’s files and state are separate and easy to find. It helps avoid mistakes, like changing the wrong environment by accident.

  • More Control Over Storage
    Some ways to save the state (called backends) don’t work well with workspaces. When each environment has its own folder, I can set the storage details exactly for that environment.

  • Simpler Automation
    In automation tools (like CI/CD), it’s easier to run commands in separate folders than to switch workspaces. This makes the automation simpler and less error-prone.

  • Better for Big Projects
    Workspaces work well for small projects with a few environments. But when you have many environments, clouds, and teams, using folders works better and is easier to manage.

2. Why Terragrunt & Terragrunt files:

Terragrunt is a wrapper around Terraform that makes managing multiple environments and clouds much easier by:

  • Reducing Code Duplication: The root root.hcl file holds common Terraform settings like providers, backend configuration, and shared variables so you don’t repeat these in every environment folder.

  • Hierarchical Configuration: Each environment folder has its own terragrunt.hcl file that includes the root root.hcl and overrides or adds settings specific to that environment, such as variables.

  • Simplified Remote State Management: Terragrunt automates backend setup, locking, and state isolation per environment, helping avoid mistakes.

  • Dependency Management: You can declare dependencies between modules or stacks in your terragrunt.hcl files, ensuring correct deployment order.

  • Convenient CLI: Terragrunt commands wrap Terraform commands, automatically applying the right config and environment variables.

Terragrunt Configuration Files in This Project

  • root.hcl (at repo root): Contains shared configurations used by all environments, such as default provider settings, common variables, and a generic backend configuration.

  • terragrunt.hcl (in each env folder): These files inherit from root.hcl and override specific settings per environment or cloud, like variable values or module inputs.

  • .tflint.hcl: This file configures TFLint, a tool that helps catch errors and enforces best practices in Terraform code.

3. Folder Structure with Terragrunt

Integrating Terragrunt and modifying previous file structure by replacing backend.tf and providers.tf with terragrunt.hcl files:

multi-cloud-terraform/
├── aws/                               
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│
├── azure/                            
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│
├── modules/                         
│   ├── aws/
│   │   ├── compute/
│   │   │   ├── main.tf
│   │   │   ├── variables.tf
│   │   │   └── outputs.tf
│   └── azure/
│       └── storage/
│           ├── main.tf
│           ├── variables.tf
│           └── outputs.tf
├── envs/                            
│   ├── dev/
│   │   ├── aws/
│   │   │    └── terragrunt.hcl     
│   │   │    
│   │   └── azure/
│   │       └── terragrunt.hcl 
│   │       
│   ├── test/
│   │   ├── aws/
│   │   │   └── terragrunt.hcl 
│   │   │   
│   │   └── azure/
│   │        └── terragrunt.hcl 
│   │      
│   └── prod/
│       ├── aws/
│       │   └── terragrunt.hcl 
│       │   
│       └── azure/
│            └── terragrunt.hcl 
├── scripts/                        
│   ├── deploy.sh
│   ├── validate.sh
│   └── plan.sh
├── root.hcl                 
├── .tflint.hcl                    
├── .pre-commit-config.yaml       
└── README.md

What each folder and file is for:

  • modules/: Reusable Terraform modules organized by cloud (aws/, azure/).

  • envs/: Environment-specific configurations per cloud (e.g., dev/aws/, prod/azure/), contains terragrunt.hcl and terraform.tfvars files for each environment-cloud combination.

  • scripts/: Helper scripts for deployment, validation, planning, and automation.

  • terragrunt.hcl: Root Terragrunt configuration with shared/common settings.

  • .tflint.hcl: Configuration for Terraform linter to enforce best practices.

  • .pre-commit-config.yaml: Configuration for pre-commit hooks to run automated checks before code commits.

  • readme.md: Documentation including project overview, setup instructions, and usage guidelines.

4. How Terragrunt Works?

After understanding the folder structure, the real power of Terragrunt comes from how it eliminates repetition using a concept called configuration inheritance.

Let’s break this down with an example to see how Terragrunt helps manage shared configuration (like backends and providers) and environment-specific settings cleanly.

Root Terragrunt Configuration (root.hcl)

The root-level file is placed at the root of repository. It contains:

  • Remote backend configuration

  • Provider configuration

  • Common variables used across environments

  • Tags or metadata (e.g., project name, owner)

Here’s an example of a root root.hcl file for AWS:

remote_state {
  backend = "s3"
  config = {                 
    bucket  = "multi-cloud-multi-env-bucket"     # Note: I am using the latest version of Terraform,
    key     = "${local.environment}/shared.tfstate"       # which no longer requires using DynamoDB 
    region  = local.default_aws_region                    # for state locking.
    use_lockfile = true
  }
}

generate "aws_provider" {
  path      = "provider_aws.tf"
  if_exists = "overwrite"
  contents  = <<EOF
provider "aws" {
  region = "${local.default_aws_region}"
}
EOF
}

generate "azure_provider" {
  path      = "provider_azure.tf"
  if_exists = "overwrite"
  contents  = <<EOF
provider "azurerm" {
  features {}
  subscription_id = "${local.azure_subscription_id}"
}
EOF
}

5. Running Terragrunt in Each Environment

Now that we have folder structure, modules, and Terragrunt configuration set up, here is how you can run Terraform using Terragrunt for each environment and cloud provider.

Add Your AWS Account ID and Azure subscription ID in root.hcl

Before running anything, make sure you have added your AWS Account ID and Azure Subscription ID in the locals block of your root.hcl.

Example snippet for root.hcl locals:

locals {
  project_name          = "my-multi-cloud-project"
  default_aws_region    = "us-east-1"
  default_azure_region  = "eastus"
  aws_account_id        = "aws-account-id" 
  azure_subscription_id = "azure-subscription-id"

  common_tags = {
    Project   = local.project_name
    ManagedBy = "Terragrunt"
  }

  path_parts  = split("/", replace(path_relative_to_include(), "envs/", ""))
  environment = length(local.path_parts) > 0 ? local.path_parts[0] : "dev"
  provider    = length(local.path_parts) > 1 ? local.path_parts[1] : "aws"
}

Running Terragrunt Commands Per Environment and Cloud

Each environment (dev, test, prod) has its own folder under envs/, and within each environment, separate folders for AWS and Azure exist. To work on a particular environment and cloud, navigate into the corresponding folder and run Terragrunt commands from there.

For example, to plan and apply infrastructure changes in Dev environment on AWS:

cd envs/dev/aws
terragrunt plan
terragrunt apply

Similarly, for Prod environment on Azure:

cd envs/prod/azure
terragrunt plan
terragrunt apply

After executing terragrunt commnds in all the environment below files got created in S3 backend:

Automation and Scripting

You can also automate this by creating simple shell scripts (like the ones in my scripts/ folder) to run Terragrunt commands for specific environments, helping reduce human errors.

TFlint warning

tflint is a Terraform linter tool used to analyze your Terraform code for potential errors, best practices, and policy violations before you apply your infrastructure changes. It helps catch issues early, improving code quality and preventing runtime errors.

In this case, when running tflint, the following warnings appeared in my Terraform code:

  1. Missing required_version attribute:
    Terraform recommends specifying a required_version block to define the Terraform version your code is compatible with. This helps ensure consistency across environments.

  2. Missing version constraint for provider azurerm:
    Your Terraform configuration uses the azurerm provider, but there is no version constraint specified under required_providers. Defining provider versions helps avoid unexpected breaking changes when providers update.

After running terragrunt apply command in test folder one and terragrunt destroy in prod folder

Thank you for taking time to read my article. If I have overlooked any steps or missed any details, please don't hesitate to get in touch.

Feel free to reach out to me anytime Contact me

~ Palak Bhawsar ✨

0
Subscribe to my newsletter

Read articles from Palak Bhawsar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Palak Bhawsar
Palak Bhawsar

DevOps Engineer with 4+ years of experience in cloud automation, CI/CD, and infrastructure optimisation, I specialise in streamlining deployments, enhancing system resilience, and reducing operational costs.