Using Terragrunt to deploy to Azure

This is the guide I wish I had

ยท

21 min read

I finally had the chance to play with Terragrunt. Unfortunately, I didn't find much content for using Terragrunt for Azure deployments. So, here it is. The guide I wish I had.

What this guide is not

  • An introduction to Terraform

  • An introduction to Terragrunt

  • An introduction to Azure

Instead, this guide will cover how to set up an opinionated Terragrunt "live" repository so that you can deploy across multiple subscriptions, regions, resource groups, and application environments.

If you're looking for an overview of Terraform and how to use it with Azure, there's a great lab by HashiCorp that's free.

If you're looking for an intro to Terragrunt, the Terragrunt docs is a great place to start.

๐Ÿ“• Also, Terragrunt makes it easier to implement some of the ideas called out in the Terraform Up and Running book.

The demo

Here's a repository I used to create a demo to deploy to Azure.

fgauna12/terragrunt-azure-example - GitHub

Here's a backup link in case the fancy card stops working ๐Ÿ‘†๐Ÿฝ.

The directory structure looks like this.

 .
โ”œโ”€โ”€ terragrunt.hcl
โ””โ”€โ”€ vs-enterprise 
    โ”œโ”€โ”€ staging 
    โ”‚   โ”œโ”€โ”€ eastus
    โ”‚   โ”‚   โ”œโ”€โ”€ region.hcl
    โ”‚   โ”‚   โ””โ”€โ”€ spoke-vnet
    โ”‚   โ”‚       โ””โ”€โ”€ terragrunt.hcl
    โ”‚   โ”œโ”€โ”€ env.hcl
    โ”‚   โ”œโ”€โ”€ global
    โ”‚   โ”‚   โ”œโ”€โ”€ region.hcl
    โ”‚   โ”‚   โ””โ”€โ”€ resource_groups
    โ”‚   โ”‚       โ”œโ”€โ”€ terragrunt.hcl
    โ”‚   โ””โ”€โ”€ westus
    โ”‚       โ”œโ”€โ”€ region.hcl
    โ”‚       โ””โ”€โ”€ spoke-vnet
    โ”‚           โ””โ”€โ”€ terragrunt.hcl
    โ””โ”€โ”€ subscription.hcl

This example is heavily inspired by the AWS terragrunt demo.

So, if you want to follow along, clone the repository, then I'll walk you through customizing to your scenario.

Okay, about the file structure

For me, the folder/file structure is one of the most appealing aspects of Terragrunt. I like that someone has put thought into and shared their experience on promoting Terraform across multiple environments for many large-scale applications.

  • terragrunt.hcl - We'll talk about this last

  • vs-enterprise - A folder for my Visual Studio Enterprise subscription

  • vs-enterprise/staging - A folder for my staging environment

  • vs-enterprise/staging/eastus - A folder for resources in the East US region

Let's walk through all the folders.

First, the subscriptions ๐Ÿ”‘

Some organizations have separate subscriptions per workload and per environment. Some organizations just have a "non-production" and a "production subscription."

Therefore, you will want to create as many folders as you have subscriptions. As you'll see later, this will help you switch subscriptions by simply changing the directory from which you run terragrunt apply. I like it because it is more obvious to see which subscription I am deploying to.

So, if you have a "non-production" and "production," then you're going to have these folders in your repository.

.
โ”œโ”€โ”€ azsub-non-production
โ””โ”€โ”€ azsub-production

If you have a more complex subscription strategy, like subscription per app and environment, then you're going to have more folders.

.
โ”œโ”€โ”€ azsub-fabrikam-app-dev
โ”œโ”€โ”€ azsub-fabrikam-app-prod
โ””โ”€โ”€ azsub-fabrikam-app-test

Please note: I pretend to use a fancy naming convention for my subscription. If you have messy subscription names, I think it's ok to have messy subscription folders.

Lastly, you'll want to create a subscription.hcl file in each subscription folder. In here, add the following contents. Make sure to add the subscription_id to each subscription file.

# vs-enterprise/subscription.hcl
locals {
    subscription_id = "<< your subscription id >>"
}

Next, the environment ๐Ÿ“ฆ

Like always, it depends on how many environments your applications will have. You could have just "staging" or "production" environments, or you could have "dev", "test", "prod."

I create a shared or management folder for resources that don't necessarily correspond to an environment. For example, you might have a Log Analytics workspace that's shared across all non-prod environments.

In my example, I am using a single environment - staging.

But then, in each environment folder, create an env.hcl file. Make sure to change the environment for each env.hcl file.

# vs-enterprise/staging/env.hcl
locals {
  environment = "staging"
}

The region ๐ŸŒŽ

Depending on your architecture, create a folder for each region. I make a global folder for resources that don't necessarily correspond to a region.

Examples of global resources are resource groups, Azure Front Door, Azure DNS, Azure Traffic Manager, Log Analytics workspace, etc. If you're a person that doesn't like to have resources from different regions inside of the same resource group, then you don't have to consider it a "global" resource.

For my example, if I am deploying to multiple regions, then my folders could be:

  • East - A spoke virtual network

  • West - Another spoke virtual network

  • Global - Create the resource groups necessary for the virtual networks. More on dependencies later.

In each region folder, create a region.hcl file.

# vs-enterprise/staging/eastus/region.hcl
locals {
  location = "eastus"
}

But what about the global folder? You'll still want to set a region. Most Azure global resources still require that you pick a region, for example, resource groups and Azure WAN. So, pick a "default" region for all of these global resources.

Finally, the Terragrunt file ๐Ÿ“„

You will have to break up your system architecture into smaller pieces. This is one of the strengths of Terraform/Terragrunt. So, find "seams" in your architecture where you break things up into "modules." This takes some experience and practice and time.

Once you have the modules, you create the Terragrunt configuration. So, in my example, I will be deploying a virtual network.

So, I created a directory called spoke-vnet under the East and West US regions. Then, I created a Terragrunt.hcl file.

# vs-enterprise/staging/eastus/spoke-vnet/terragrunt.hcl
terraform {
  source = "tfr:///Azure/vnet/azurerm//?version=2.6.0"
}

include {
  path = find_in_parent_folders()
}

dependencies {
  paths = ["../../global/resource_groups"]
}

dependency "resource_groups" {
  config_path = "../../global/resource_groups"

  mock_outputs = {
    vnet_resource_group_name = "rg-terragrunt-mock-001"
  }
  mock_outputs_merge_with_state = true
}

locals {
  env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
  environment = local.env_vars.locals.environment

  region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
  location = local.region_vars.locals.location
}

inputs = {
  vnet_name           = "vnet-spoke-${local.environment}-${local.location}-001"
  resource_group_name = dependency.resource_groups.outputs.vnet_resource_group_name
  address_space       = ["10.0.0.0/16"]
  subnet_prefixes     = ["10.0.1.0/26", "10.0.2.0/24", "10.0.3.0/24", "10.0.4.0/24"]
  subnet_names        = ["AzureBastionSubnet", "Management", "Tools", "Workloads"]
  location            = local.location

  tags = {
    environment = local.environment
  }
}

Then, I created another Terragrunt.hcl file in my westus folder. The contents are mostly the same with the exception of the address spaces (inputs.address_space and inputs.subnet_prefixes).

A couple of important things to call out:

  • The source - Under the terraform block, there's the opportunity to configure the source of the Terraform module. In my example, I use a public Terraform module to create a virtual network.

  • The include - We're saying to include the first terragrunt.hcl file in the parent directories. Because our other Terragrunt files like subscription.hcl don't follow the default name, they will be ignored with this statement. Later, we'll create a terragrunt.hcl file at the repository's root. ๐Ÿ˜Š When we create a terragrunt.hcl file in the root of the repository, then this include statement will discover it.

  • The inputs - The inputs are the input variables passed into the module. Notice how I am using locals to build some inputs.

  • The locals - Much like proper Terraform, you can have locals. These variables are truly local to the file. You won't be able to consume locals defined in the parent directories like the subscription.hcl or env.hcl. However, you can force this behavior by loading them explicitly:

locals {
    env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
    environment = local.env_vars.locals.environment
}
  • The dependencies - Remember how we never created a resource group for our virtual network? Well, we want to use Terraform to create the resource group as well. The problem is that we can't write proper Terraform inside of a Terragrunt configuration. We could create a resource_group.tf next to the terragrunt.hcl file and the resource group would be created, but there's no way to refer to the attributes of that resource or do a depends_on from Terragrunt file to Terraform configuration. Instead, the annoying part is that we'll have to create a separate module for just the resource groups to develop an inter-module dependency. Notice that there are two kinds of blocks: dependencies and dependency blocks The dependencies block is only required to specify the order of dependencies for when you use terragrunt run-all. The dependency block declares the actual dependency and refers to outputs from the depending module. For example, to get the name of the resource group that hasn't been created, we refer to the resource group module by calling dependency.resource_groups.outputs.vnet_resource_group_name.

Alright, fine, let's create the resource-group module. ๐Ÿ˜ฌ

Create resource-groups folder. I placed mine under the global region. Here, you will define the resource groups that you want to create and deploy your infrastructure into.

Note: This pattern of creating a module also applied to me when I wanted to use data blocks since you can't use data blocks in your Terragrunt configuration. In this example, I am not using data elements.

# vs-enterprise/staging/global/resource_groups/resource_group.tf
resource "azurerm_resource_group" "vnet_resource_group" {
  name     = var.vnet_resource_group_name
  location = var.location
}

Then you need some variables.

# vs-enterprise/staging/global/resource_groups/variables.tf
variable "location" {
  type  = string
  description = "The Azure Region to use"
}

variable vnet_resource_group_name {
  type        = string
  description = "The name of the vnet resource group"
}

And, some outputs so that you can create inter-module dependencies.

# vs-enterprise/staging/global/resource_groups/outputs.tf
output vnet_resource_group_name {
  value = var.vnet_resource_group_name
  depends_on = [azurerm_resource_group.vnet_resource_group]
}

Lastly, a Terragrunt configuration file.

# vs-enterprise/staging/global/resource_groups/terragrunt.hcl
include {
  path = find_in_parent_folders()
}

inputs = {
  vnet_resource_group_name = "rg-example-vnet"
}

Dude, what about the backend? And... the provider?

Yes, I know. We haven't talked about where the Terraform state will be stored. Bad news. This part is a little complicated.

To define a Terraform backend, we could go into each terragrunt.hcl file for each component, environment, subscription, and region. But, that would mean copying and pasting code. Instead, we could define the backend configuration in one place.

As promised, remember this from our terragrunt.hcl file?

# vs-enterprise/staging/eastus/spoke-vnet/terragrunt.hcl
include {
  path = find_in_parent_folders()
}

Well, Terragrunt will be looking for another terragrunt.hcl file in the parent directories. So let's define it at the root of your repository.

# terragrunt.hcl
locals {
  # Automatically load subscription variables
  subscription_vars = read_terragrunt_config(find_in_parent_folders("subscription.hcl"))

  # Automatically load region-level variables
  region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))

  # Automatically load environment-level variables
  environment_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))

  location              = local.region_vars.locals.location
  environment       = local.environment_vars.locals.environment
  subscription_id   = local.subscription_vars.locals.subscription_id
}

# Generate Azure providers
generate "versions" {
  path      = "versions_override.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
    terraform {
      required_providers {
        azurerm = {
          source = "hashicorp/azurerm"
          version = "2.95.0"
        }
      }
    }

    provider "azurerm" {
        features {}
        subscription_id = "${local.subscription_id}"
    }
EOF
}

remote_state {
    backend = "azurerm"
    config = {
        subscription_id = "${local.subscription_id}"
        key = "${path_relative_to_include()}/terraform.tfstate"
        resource_group_name = "rg-terragrunt-example-001"
        storage_account_name = "stterragruntexample001"
        container_name = "environment-states"
    }
    generate = {
        path      = "backend.tf"
        if_exists = "overwrite_terragrunt"
    }
}

# Configure root level variables that all resources can inherit. This is especially helpful with multi-subscription configs
# where terraform_remote_state data sources are placed directly into the modules.
inputs = merge(
  local.subscription_vars.locals,
  local.region_vars.locals,
  local.environment_vars.locals
)

Did you shed a tear? ๐Ÿ˜ข Yes, it's ugly and it looks complicated. I'll walk you through it.

  • The locals - The key to understanding the locals is the context where this terragrunt.hcl file will be run from. It always runs from the context of the leaf folder (the module folder). So, when we deploy our spoke-vnet module with Terragrunt, it will load the parent terragrunt.hcl file. Then, it will try to load subscription locals from a Terragrunt config file called subscription.hcl located in one of the parent directories.

  • The generate block - Essentially, you will generate a block to specify the provider for all the leaf Terraform modules. This means one place to update the provider version! Notice how you can use local references to select which subscription you want to deploy to. Depending on which folder the "leaf folder" is located in, it's going to use a different subscription.

  • The remote state - Here we tell Terragrunt to generate backend configuration and place it in the backend.tf file for each leaf folder.

  • Input magic - Remember how we were adding locals blocks in the subscription.hcl and other non-default Terragrunt files? Essentially, we will be reading all of these local variables and merging them as inputs. So, let's say that all of your Terraform modules had an input variable of location for the Azure region. We won't have to specify location = eastus a million times with this input magic.

Almost there! ๐Ÿ™๐Ÿพ We just need a few things on the Azure side.

Wait โœ‹๐Ÿฝ, some prep work

We defined a storage account for our backend and we haven't created it yet.

First, create a resource group for the Terraform backend.

  • Name: rg-terragrunt-example-001

  • Location: eastus

Next, create the storage account that will hold the Terraform backend. You will also need a storage account container.

  • Name: stterragruntexample001

  • Location: eastus

  • Redunancy: LRS

  • Container Name: environment-states

Also, remember that our code is creating the resource group for the virtual network? Well, if we do a terragrunt run-all plan, it will fail since no resource groups exist yet. So, let's create a "mock" resource group.

  • Name: rg-terragrunt-mock-001

  • Location: eastus

In reality, this "mocking" resource group will only be used when new infrastructure is being created for the first time. This is unique to Azure because, in AWS, resource groups are not required to create resources. This is also unique to Terragrunt for Azure because we can't add raw Terraform code in a Terragrunt configuration file. I guess the only way to avoid this issue is to create resource groups from within the modules themselves. But, creating resource groups within modules doesn't sound effective either since we would have less control over the tags at the resource group level.

Lastly, in that storage account, create a private container called environment-states. This is where the Terraform states will go. Don't worry; environments will be separated through sub-folders.

Ready to run it?

This part is so cool. Set the current directory of your shell to the region folder. Then run terragrunt run-all plan.

Terragrunt will find all the terragrunt.hcl files and then run terragrunt plan from each folder (It won't work from the root of the repository).

Oh my...

โฏ terragrunt run-all plan
INFO[0000] The stack at /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise will be processed in the following order for command plan:
Group 1
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/global/resource_groups

Group 2
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/eastus/spoke-vnet
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/westus/spoke-vnet


Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azurerm_resource_group.vnet_resource_group will be created
  + resource "azurerm_resource_group" "vnet_resource_group" {
      + id       = (known after apply)
      + location = "eastus"
      + name     = "rg-example-vnet"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + vnet_resource_group_name = "rg-example-vnet"

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azurerm_subnet.subnet[0] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.1.0/26",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "AzureBastionSubnet"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-westus-001"
    }

  # azurerm_subnet.subnet[1] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.2.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Management"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-westus-001"
    }

  # azurerm_subnet.subnet[2] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.3.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Tools"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-westus-001"
    }

  # azurerm_subnet.subnet[3] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.4.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Workloads"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-westus-001"
    }

  # azurerm_virtual_network.vnet will be created
  + resource "azurerm_virtual_network" "vnet" {
      + address_space         = [
          + "10.0.0.0/16",
        ]
      + dns_servers           = []
      + guid                  = (known after apply)
      + id                    = (known after apply)
      + location              = "eastus"
      + name                  = "vnet-spoke-staging-westus-001"
      + resource_group_name   = "rg-terragrunt-mock-001"
      + subnet                = (known after apply)
      + tags                  = {
          + "environment" = "staging"
        }
      + vm_protection_enabled = false
    }

Plan: 5 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + vnet_address_space = [
      + "10.0.0.0/16",
    ]
  + vnet_id            = (known after apply)
  + vnet_location      = "eastus"
  + vnet_name          = "vnet-spoke-staging-westus-001"
  + vnet_subnets       = [
      + (known after apply),
      + (known after apply),
      + (known after apply),
      + (known after apply),
    ]

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # azurerm_subnet.subnet[0] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.1.0/26",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "AzureBastionSubnet"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-eastus-001"
    }

  # azurerm_subnet.subnet[1] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.2.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Management"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-eastus-001"
    }

  # azurerm_subnet.subnet[2] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.3.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Tools"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-eastus-001"
    }

  # azurerm_subnet.subnet[3] will be created
  + resource "azurerm_subnet" "subnet" {
      + address_prefix                                 = (known after apply)
      + address_prefixes                               = [
          + "10.0.4.0/24",
        ]
      + enforce_private_link_endpoint_network_policies = false
      + enforce_private_link_service_network_policies  = false
      + id                                             = (known after apply)
      + name                                           = "Workloads"
      + resource_group_name                            = "rg-terragrunt-mock-001"
      + virtual_network_name                           = "vnet-spoke-staging-eastus-001"
    }

  # azurerm_virtual_network.vnet will be created
  + resource "azurerm_virtual_network" "vnet" {
      + address_space         = [
          + "10.0.0.0/16",
        ]
      + dns_servers           = []
      + guid                  = (known after apply)
      + id                    = (known after apply)
      + location              = "eastus"
      + name                  = "vnet-spoke-staging-eastus-001"
      + resource_group_name   = "rg-terragrunt-mock-001"
      + subnet                = (known after apply)
      + tags                  = {
          + "environment" = "staging"
        }
      + vm_protection_enabled = false
    }

Plan: 5 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + vnet_address_space = [
      + "10.0.0.0/16",
    ]
  + vnet_id            = (known after apply)
  + vnet_location      = "eastus"
  + vnet_name          = "vnet-spoke-staging-eastus-001"
  + vnet_subnets       = [
      + (known after apply),
      + (known after apply),
      + (known after apply),
      + (known after apply),
    ]

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

But, wait, should you use run-all all the time?

No, the Terragrunt team comments that they originally designed this capability to stand up an entire infrastructure stack from scratch. Once the infrastructure is up, they recommend using terragrunt plan and apply at the individual modules.

Alright, do it. Apply. ๐Ÿคฏ

I love this part. Run terragrunt run-all apply -auto-approve.

โฏ terragrunt run-all apply -auto-approve
INFO[0000] The stack at /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise will be processed in the following order for command apply:
Group 1
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/global/resource_groups

Group 2
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/eastus/spoke-vnet
- Module /Users/facundo/Repos/terragrunt-azure-example/vs-enterprise/staging/westus/spoke-vnet

Are you sure you want to run 'terragrunt apply' in each folder of the stack described above? (y/n) y
azurerm_resource_group.vnet_resource_group: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet]

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply":

  # azurerm_resource_group.vnet_resource_group has changed
  ~ resource "azurerm_resource_group" "vnet_resource_group" {
        id       = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet"
        name     = "rg-example-vnet"
      + tags     = {}
        # (1 unchanged attribute hidden)
    }


Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like
to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

vnet_resource_group_name = "rg-example-vnet"
azurerm_virtual_network.vnet: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001]
azurerm_virtual_network.vnet: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001]
azurerm_subnet.subnet[3]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Workloads]
azurerm_subnet.subnet[2]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Tools]
azurerm_subnet.subnet[0]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/AzureBastionSubnet]
azurerm_subnet.subnet[1]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Management]
azurerm_subnet.subnet[3]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Workloads]
azurerm_subnet.subnet[2]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Tools]
azurerm_subnet.subnet[0]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/AzureBastionSubnet]
azurerm_subnet.subnet[1]: Refreshing state... [id=/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Management]

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply":

  # azurerm_subnet.subnet[0] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/AzureBastionSubnet"
        name                                           = "AzureBastionSubnet"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[1] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Management"
        name                                           = "Management"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[2] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Tools"
        name                                           = "Tools"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[3] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Workloads"
        name                                           = "Workloads"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_virtual_network.vnet has changed
  ~ resource "azurerm_virtual_network" "vnet" {
        id                      = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001"
        name                    = "vnet-spoke-staging-westus-001"
      ~ subnet                  = [
          + {
              + address_prefix = "10.1.1.0/26"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/AzureBastionSubnet"
              + name           = "AzureBastionSubnet"
              + security_group = ""
            },
          + {
              + address_prefix = "10.1.2.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Management"
              + name           = "Management"
              + security_group = ""
            },
          + {
              + address_prefix = "10.1.3.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Tools"
              + name           = "Tools"
              + security_group = ""
            },
          + {
              + address_prefix = "10.1.4.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Workloads"
              + name           = "Workloads"
              + security_group = ""
            },
        ]
        tags                    = {
            "environment" = "staging"
        }
        # (7 unchanged attributes hidden)
    }


Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like
to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

vnet_address_space = tolist([
  "10.1.0.0/16",
])
vnet_id = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001"
vnet_location = "eastus"
vnet_name = "vnet-spoke-staging-westus-001"
vnet_subnets = [
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/AzureBastionSubnet",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Management",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Tools",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-westus-001/subnets/Workloads",
]

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply":

  # azurerm_subnet.subnet[0] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/AzureBastionSubnet"
        name                                           = "AzureBastionSubnet"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[1] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Management"
        name                                           = "Management"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[2] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Tools"
        name                                           = "Tools"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_subnet.subnet[3] has changed
  ~ resource "azurerm_subnet" "subnet" {
        id                                             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Workloads"
        name                                           = "Workloads"
      + service_endpoint_policy_ids                    = []
      + service_endpoints                              = []
        # (6 unchanged attributes hidden)
    }

  # azurerm_virtual_network.vnet has changed
  ~ resource "azurerm_virtual_network" "vnet" {
        id                      = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001"
        name                    = "vnet-spoke-staging-eastus-001"
      ~ subnet                  = [
          + {
              + address_prefix = "10.0.1.0/26"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/AzureBastionSubnet"
              + name           = "AzureBastionSubnet"
              + security_group = ""
            },
          + {
              + address_prefix = "10.0.2.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Management"
              + name           = "Management"
              + security_group = ""
            },
          + {
              + address_prefix = "10.0.3.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Tools"
              + name           = "Tools"
              + security_group = ""
            },
          + {
              + address_prefix = "10.0.4.0/24"
              + id             = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Workloads"
              + name           = "Workloads"
              + security_group = ""
            },
        ]
        tags                    = {
            "environment" = "staging"
        }
        # (7 unchanged attributes hidden)
    }


Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.

โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

No changes. Your infrastructure matches the configuration.

Your configuration already matches the changes detected above. If you'd like
to update the Terraform state to match, create and apply a refresh-only plan:
  terraform apply -refresh-only

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

vnet_address_space = tolist([
  "10.0.0.0/16",
])
vnet_id = "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001"
vnet_location = "eastus"
vnet_name = "vnet-spoke-staging-eastus-001"
vnet_subnets = [
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/AzureBastionSubnet",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Management",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Tools",
  "/subscriptions/<< redacted subscription id >>/resourceGroups/rg-example-vnet/providers/Microsoft.Network/virtualNetworks/vnet-spoke-staging-eastus-001/subnets/Workloads",
]

That's it! All resources were created. You'll notice that the output looks like someone iterated through all the folders and ran terraform apply.

Summary ๐Ÿ™๐Ÿพ

As I was learning Terragrunt, I wanted to like it. I am starting to, and I think I will end up liking it more. There were some quirks that are unique when using Azure. For example, AWS regions are king and therefore the AWS Terraform Provider has to be configured with the region. In Azure, subscriptions are king and it's wise to configure the Azure Terraform provider with the subscription ID. Also, Azure resource groups are mandatory to deploy resources and you have to make some weird decisions around when you create your resource groups and whether you pass them as inputs to your infrastructure modules. Lastly, don't forget that you might have to make a data module to query Azure AD for some Object IDs.

If you followed, this guide, I hope you found it useful. Leave a comment below, especially if you have some tips and tricks too. I would love to hear from you. ๐Ÿ˜Š

ย