Working with multiple environments in Terraform

Published on Sep 02

The past few year, Terraform has emerged as the go-to tool in the ever-evolving world of infrastructure management. It allows teams to define, provision and manage their infrastructure with great efficiency. As projects grow and evolve, the need to separate environments for development, testing and production grows with it. After all, it’s important to be able to test and develop new features before pushing them to production, just like developers do with applications.

In this post, I’d like to discuss my personal insights on working with multiple environments in Terraform. I’ll go over 2 native options and one third-party tool, called Terragrunt. Whether you’re a seasoned infrastructure guru or a curious newcomer, we’ll (hopefully) be able to learn something new as we embark on this journey.

Different directories

This is our first option, using different directories to manage various enfironments in Terraform. This allows us to organize our project into separate directories, each representing a specific enfironment. Each environment makes use of a set of shared modules which minimize code repetition.

In these directories, we’ll define our variables, configure the modules and configure a state backend. By doing this, each directory has its own configuration (based on predefined and shared modules) and its own state. An example of how a project using different directories to separate environments is organized, can be found below.

└── README.md
└── modules
    └── network
    └── kubernetes
    └── ...
└── prod
    └── main.tf
    └── terraform.tfvars
    └── variables.tf
└── test
    └── main.tf
    └── terraform.tfvars
    └── variables.tf

Configuring an environment

Once we’ve create our directory structure, we can go ahead and configure the resources for an environment. As an example, we’ll set up the test environment using the resources inside our shared modules.

module "network" {
  source          = "../modules/network"
  cidr_block      = "10.40.0.0/16"
  subnets_private = ["10.40.0.0/19", "10.40.64.0/19", "10.40.128.0/19"]
  subnets_public  = ["10.40.32.0/20", "10.40.96.0/20", "10.40.160.0/20"]
}

In the example above, I kept things simple by not using variables. But it is completely possible to use variables. All you need to do is create a a variables.tf and terraform.tfvars file in the environment’s directory. These files are environment-specific, which allows you to set separate variables for each environment.

Once everything has been configured, you can initialize, plan and apply the resources. This can be done from the root of the project by adding the -chdir= argument followed by the environment’s folder to the Terraform command.

terraform -chdir=./prod/ init
terraform -chdir=./prod/ plan -out=plan.out
terraform -chdir=./prod/ apply

Creating a new environment is as easy as creating a new directory (for example test) and configuring it like you did for prod (but of course with the right environment-specific values).

Thoughts

This is my personal favorite, as it keeps a clear overview over the environments and their configuration while not introducing new tools. It allows for new team members to get up to speed fairly quickly (with the right documentation) and does what it needs to do.

AdvantagesDisadvantages
Clear separation between environmentsPotential code duplication as you the same module for different environments
Capable of having specific features in specific environmentsMaintaining consistent module versions through environments can be difficult
Consistent and reusable code by using shared modules with environment-specific configurationSeparate state files for each environment require additional configuration
Simple deployment workflow by passing the -chdir argument

Terraform workspaces

The second option I’d like to discuss is using Terraform workspaces. They provide a powerful way to separate environments within the same Terraform code. Each workspace acts a separate context, allowing you to set specific variables, resources,.. without needing separate directories or code duplication.

Terraform workspaces are especially useful when your environments have minor differences. They allow you to maintain a single codebase while having environment-specific configurations. This allows you to define your modules only once and configure them based on the value of the terraform.workspace variable, but more on that later.

An example of how a project using Terraform workspaces to separate environments is organized, can be found below.

└── README.md
└── main.tf
└── variables.tf
└── terraform.tfvars
└── modules
    └── network
    └── kubernetes
    └── ...

Managing workspaces

The CLI provides a set of subcommands that allow you to create, list, select, and delete workspaces.

CommandDescription
terraform workspace new <name>Creates a new workspace
terraform workspace listDisplays the names of all created workspaces
terraform workspace select <name>Selects a specific workspaces
terraform workspaces showShows the name of the current workspace
terraform workspace delete <name>Deletes a specific workspace

Each workspace maintains its own separate state, ensuring that resource changes in one workspace, don’t influence resources in other workspaces. By switching between workspaces, you can deploy changes to the desired environment.

Configuring an environment

Contrary to working with different directories, Terraform workspaces only require your code to be written once (there’s no - or very minimal - duplication). This means we can create our main.tf and define the resources for both test and prod (assuming they use the same).

When working with Terraform workspaces, we can use the terraform.workspace variable to retrieve the name of the currently selected workspace. You can use this variable to conditionally set values in your Terraform configuration based on the active workspace. For example, when you want to create 3 EC2 instances when you’re working on production and only 1 when you’re working on a different workspace you can do something like this:

resource "aws_instance" "example" {
	count = terraform.workspace == "prod" ? 3 : 1
	# Other instance configuration goes here...
}

The terraform.workspace variable is not only useful for conditional configurations, but it also comes in hand when you want to select values from a map in Terraform. This becomes particularly useful when you want specific values for resource or module arguments based on the active workspace. In the example below, I have my “network” module which gets configured using values which get extracted from maps based on the terraform.workspace variable.

locals {
	vpc_cidr_block = {
		prod = "10.40.0.0/16"
		test = "10.30.0.0/16"
	}
	vpc_subnets_private = {
		prod = ["10.40.0.0/19", "10.40.64.0/19", "10.40.128.0/19"]
		test = ["10.30.0.0/19", "10.30.64.0/19", "10.30.128.0/19"]
	}
	vpc_subnets_public = {
		prod = ["10.40.32.0/20", "10.40.96.0/20", "10.40.160.0/20"]
		test = ["10.30.32.0/20", "10.30.96.0/20", "10.30.160.0/20"]
	}
}

module "network" {
	source          = "modules/network"
	cidr_block      = var.vpc_cidr_block[terraform.workspace]
	subnets_private = var.vpc_subnets_private[terraform.workspace]
	subnets_public  = var.vpc_subnets_public[terraform.workspace]
}

Thoughts

This option comes as a close second favorite in this list. I like how easy it is to switch workspaces and how clean you can keep your code using the terraform.workspace variable. The only real issues I have is the fact that you can’t have different backends and module version for workspaces. When developing new features, I’d like to test my modules on a test environment without influencing the production environment. Once there is a solution for that, this might just be my personal favorite.

AdvantagesDisadvantages
Integrated in Terraform, no 3rd party tooling necessaryOnly one backend for Terraform states (but one state per workspace)
Single Terraform configuration for multiple environmentsYou cannot have a different version of a module for different workspaces
Very simple environment switching by using the terraform workspace commandCan be difficult to depend on resources between workspaces
Automatic state management, each workspace automatically has its own state

Terragrunt

The third and final option I’d like to discuss is a 3rd party tool called Terragrunt. Operating as a wrapper around Terraform, Terragrunt introduces functionalities such as code reuse, dependency management, and hierarchical configuration. This tool is specifically crafted to enhance the management of diverse Terraform environments.

Terragrunt allows you to create reusable modules and configurations, providing a way to share configurations across different projects and environments. Its configuration structure simplifies the process of defining settings at various levels, ranging from the project’s root down to the environment and module levels. This architectural approach streamlines the management of configurations while effectively mitigating the duplication of code.

Let’s take a look at how we need to set up our project structure in order to use Terragrunt. In the example below, I’ve used the same set-up as the past two options (two environments, test and prod, and two modules, network and kubernetes).

└── README.md
└── terragrunt.hcl
└── modules
    └── network
    └── kubernetes
    └── ...
└── prod
	└── env.hcl
	└── network
		└── terragrunt.hcl
	└── kubernetes
		└── terragrunt.hcl
└── test
    └── env.hcl
    └── network
		└── terragrunt.hcl
	└── kubernetes
		└── terragrunt.hcl

Configuring an environment

The big difference with “vanilla” Terraform is that Terragrunt requires various .hcl files, in which you tell Terragrunt what to do. In the root of the project, we’ll have a terragrunt.hcl where all the basic configuration (providers, remote states,..) are configured.

# Generate an AWS provider block
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "${local.region}"

  # Only these AWS Account IDs may be operated on by this template
}
EOF
}

# Configure Terragrunt to automatically store tfstate files in an S3 bucket
remote_state {
  backend = "s3"
  config = {
    encrypt        = true
    bucket         = "${get_env("TG_BUCKET_PREFIX", "")}terraform-state-${local.account_name}-${local.region}"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = local.region
  }
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}

Once we have that, you can create an env.hcl file for each environment containing the environment-specific variables.

# Set common variables for the environment. This is automatically pulled in in the root terragrunt.hcl configuration to feed forward to the child modules.
locals {
  environment    = "prod"
  region         = "eu-west-1"
  instance_count = 3
}

We can now create a terragrunt.hcl file in each module-directory in which you’ll be able to refer to the Terraform module with its inputs. In this file, you can also utilise the environment-specific variables you set earlier in the env.hcl file.

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

  # Extract out common variables for reuse
  env    = local.environment_vars.locals.environment
  region = local.environment_vars.locals.region
}

# Terragrunt will copy the Terraform configurations specified by the source parameter, along with any files in the
# working directory, into a temporary folder, and execute your Terraform commands in that folder.
terraform {
  source = "../../modules/network"
}

# These are the variables we have to pass in to use the module specified in the terragrunt configuration above
inputs = {
  cidr_block      = "10.40.0.0/16"
  subnets_private = ["10.40.0.0/19", "10.40.64.0/19", "10.40.128.0/19"]
  subnets_public  = ["10.40.32.0/20", "10.40.96.0/20", "10.40.160.0/20"]
}

That should be the basics! You can create a module terragrunt.hcl file in its own directory for each module you have and repeat all those steps for a new environment. Next, we’ll see which commands we can use to manage/deploy/destroy our infrastructure using Terragrunt.

Managing environments

Terragrunt functions as a wrapper around the basic terraform command. This means what whenever we run terragrunt, it’ll run terraform in the background.

Having this in the back of our mind, let’s take a look at some of the commands we can use:

# Deploy all environments
terragrunt run-all plan -out plan.out
terragrunt run-all apply plan.out

# Deploy a single environment
cd prod
terragrunt run-all plan -out plan.out
terragrunt run-all apply plan.out

# Deploy a single module in a specific environment
cd prod/network
terragrunt run-all plan -out plan.out
terragrunt run-all apply plan.out

Thoughts

Out of the three options I’ve discussed in this post, this might be my least favorite. Although you have a great tool with lots of capabilities, I’ve frequently found it to present a rather steep learning curve for newcomers. If you have the time and the resources to really take a deep dive into Terragrunt and you have some larger projects, I believe Terragrunt is a great tool.

In scenarios where time and project scale are limited, there exist other (and maybe better) options like working with different directories and Terraform worspaces. These options may better suit projects with tighter schedules and smaller scopes, allowing for quicker and more manageable infrastructure management.

AdvantagesDisadvantages
Supports a hierarchical configuration structure, allowing you to define configurations at different levelsIntroduces an additional layer of complexity compared to using only Terraform
Offers automation capabilities for common workflowsAdditional tool you need to manage and keep up-to-date
Minimizes duplication of code (DRY)Hierarchical configuration can lead to increased complexity if not managed well
Capable of generating backend configurations per environment

I would choose…

Now, as we wrap up this exploration, let’s address the pivotal question: which option should you choose to manage multiple environments in Terraform?

Different directories when trying to keep it simple

Imagine you’re working on a small project - perhaps a Lambda function with some CloudWatch logging - and you need a straightforward deployment process. In this scenario, I’d go with using different directories as it keeps a clear separation between environments while also allowing you to deploy the project with a simple command.

Considering that your project is small (let’s assume fewer than 100 lines of code), the minimal code duplication involved should not pose a significant issue.

Terraform workspaces for larger projects

Now, suppose you’re tackling a larger project with constraints on time and resources, yet the need to deploy across multiple environments persists. They allow you to define separate variables for each environment, keep all the code in a single location (which helps with code deduplication), and allows you to easily switch between environments.

One limitation to be aware of is that Terraform workspaces do not support different module versions between environments. So, if you need to deploy version 1.1.0 of a module in development while keeping production on version 1.0.0, you might face a challenge. However, if this isn’t a critical concern, Terraform workspaces present an excellent option.

Terragrunt when you have time and resources to get used to it

Terragrunt is great; it offers a lot of features Terraform itself doesn’t offer and really enhances your workflow. However, adopting Terragrunt introduces a learning curve, especially for newcomers. Therefore, I recommend this option when you (and your team) have the luxury of time and resources to become proficient with it.

Terragrunt offers unique advantages, but it’s not a tool you’d want to rush into. Given the opportunity to master it, Terragrunt can be a game-changer for your infrastructure management.

Similar posts

Copyright © Vincent De Borger