Our teams working with Terraform often find that it is helpful to create a variable hierarchy that supports defining global, environment, and stack-specific variables. While this is a fairly basic concept, Terraform not only doesn’t give you a way to do it out of the box but actively works against supporting the concept, despite strong user desire.
Chris, BTI360 engineer and author of thirstydeveloper.io shares in this post how teams at BTI360 implement a Terraform variable hierarchy using Terragrunt and YAML files.
Example Infrastructure
Let’s say we have a simple two-stack, two-environment terraform project using Terragrunt. It might look something like this:
deployments/ dev/ # dev environment stacks database/ # dev environment database stack terragrunt.hcl # dev environment database stack terragrunt config webserver/ # dev environment webserver stack terragrunt.hcl # dev environment webserver stack terragrunt config prod/ # production environment stacks database/ terragrunt.hcl webserver/ terragrunt.hcl root.hcl # parent terragrunt config file modules/ stacks/ # stacks deployed by deployments database/ main.tf # terraform manifest for database stack webserver/ main.tf # terraform manifest for webserver stack with each of the terragrunt.hcl files including shared terragrunt configuration from the root.hcl with: # terragrunt.hcl include { path = find_in_parent_folders("root.hcl") }
Now, say both the database and webserver stacks take in a project_name
variable that is always set to sample_project
. We could create a terraform.tfvars file in each deployment directory to set this variable value, something like:
deployments/ dev/ database/ terraform.tfvars # dev database variable values terragrunt.hcl webserver/ terraform.tfvars # dev webserver variable values terragrunt.hcl prod/ database/ terraform.tfvars # prod database variable values terragrunt.hcl webserver/ terraform.tfvars # prod webserver variable values terragrunt.hcl root.hcl
where each terraform.tfvars
file contains:
project_name = "sample_project"
Following the DRY principal however, we’d prefer to just define this variable once at a global level, perhaps with a terraform.tfvars
file underneath deployments/
:
deployments/ dev/ prod/ terraform.tfvars # global variable values root.hcl # parent terragrunt config file
Then we could load this root terraform.tfvars
with the root.hcl
like so:
terraform { ... extra_arguments "load_global_variables" { commands = get_terraform_commands_that_need_vars() optional_var_files = ["${get_parent_terragrunt_dir()}/terraform.tfvars"] } }
This, however, would be a mistake.
Why tfvars Don’t Work
The tfvars approach above worked well until Terraform 0.12, which introduced a controversial feature to print warnings when values are specified for undefined variables. For instance, if we add an unused variable to deployments/terraform.tfvars
:
# deployments/terraform.tfvars project_name = "sample_project" unused = true
any stack we plan
or apply
now prints:
Warning: Value for undeclared variable The root module does not declare a variable named "unused" but a value was found in file...
The warnings are annoying enough in that they clutter the plan output, but worse still the warning states:
Using a variables file to set an undeclared variable is deprecated and will become an error in a future release.
That means we can’t reliably use tfvars files for a variable hierarchy, at least not if you have any variables defined that go unused by any stacks.
This poses a problem because it doesn’t take long for an infrastructure project to end up with some variables defined at some parent level that some stacks beneath don’t need or use, as many issue comments point out.
Hashicorp does give you a workaround. You can specify variable values using environment variables, TF_VAR_project_name=sample_project
for instance, but Terraform leaves the challenge of easily and reproducibly defining those environment variables across systems as an exercise to the developer.
We can do better.
Variable Hierarchy with Terragrunt Inputs
Terragrunt offers two key capabilities that offer us a way out.
The first is Terragrunt’s inputs attribute, which accepts an HCL object and translates each key/value pair into an environment variable passed to Terraform.
The second is that Terragrunt allows access to all of Terraform’s built-in functions within the terragrunt.hcl
/ root.hcl
files. Specifically, we have access to:
- file – for loading file contents to a string
- yamldecode – for converting a YAML string to an HCL object
- fileexists – for checking if a file exists before attempting to load it
- merge – for merging multiple HCL objects
By combining these functions with Terragrunt’s inputs
attribute, we can establish our variable hierarchy using YAML files. Here’s how:
Start by converting the terraform.tfvars
files to config.yml
files1. For instance, convert:
# deployments/terraform.tfvars project_name = "sample_project" unused = true
to:
# deployments/config.yml --- project_name: "sample_project" unused: true
You’ll end up with a structure looking like:
dev/ database/ config.yml # dev/database variable values terragrunt.hcl webserver/ config.yml # dev/webserver variable values terragrunt.hcl config.yml # dev environment variable values prod/ database/ config.yml # prod/database variable values terragrunt.hcl webserver/ config.yml # prod/webserver variable values terragrunt.hcl config.yml # prod environment variable values config.yml # global variable values root.hcl
Next, modify your root.hcl
to:
- Recursively find every config.yml between the
root.hcl
and theterragrunt.hcl
- Load each config.yml file that exists into a string using
file()
- Convert each YAML string into a HCL object using
yamldecode()
- Merge all the objects using
merge()
, ensuring lower-level objects override higher-level ones - Pass the merged object into
inputs
Here’s a sample root.hcl
that does the above:
# root.hcl locals { root_deployments_dir = get_parent_terragrunt_dir() relative_deployment_path = path_relative_to_include() deployment_path_components = compact(split("/", local.relative_deployment_path)) # Get a list of every path between root_deployments_directory and the path of # the deployment possible_config_dirs = [ for i in range(0, length(local.deployment_path_components) + 1) : join("/", concat( [local.root_deployments_dir], slice(local.deployment_path_components, 0, i) )) ] # Generate a list of possible config files at every possible_config_dir # (support both .yml and .yaml) possible_config_paths = flatten([ for dir in local.possible_config_dirs : [ "${dir}/config.yml", "${dir}/config.yaml" ] ]) # Load every YAML config file that exists into an HCL object file_configs = [ for path in local.possible_config_paths : yamldecode(file(path)) if fileexists(path) ] # Merge the objects together, with deeper configs overriding higher configs merged_config = merge(local.file_configs...) } # Pass the merged config to terraform as variable values using TF_VAR_ # environment variables inputs = local.merged_config
With this approach, we can:
- Put global variables in
deployments/config.yml
- Put environment-specific variables in
deployments/dev/config.yml
anddeployments/prod/config.yml
- Put stack specific variables in the
config.yml
next to the correspondingterragrunt.hcl
Furthermore, we can override values in higher-level config files by redefining them at lower-levels. For instance, we could redefine project_name
in deployments/dev/webserver/config.yml
as:
# deployments/dev/webserver/config.yml project_name: webserver
and that would override the value of sample_project
defined in deployments/config.yml
.
Using YAML files as our Terraform variable hierarchy has proved very successful for our teams. We hope it helps yours too. For more information on this approach, including a fully worked example, see Chris’ terraform-skeleton series on thirstydeveloper.io. Good luck!
Footnotes
- The YAML loading doesn’t play nice with
config.yml
files that are empty or contain just a---
start of document marker. You can delete the emptyconfig.yml
file and everything will work, due to thefileexists
check. If you prefer to keep the emptyconfig.yml
, the most minimal contents required are:
--- {}
Interested in Solving Challenging Problems? Work Here!
Are you a software engineer, interested in joining a software company that invests in its teammates and promotes a strong engineering culture? Then you’re in the right place! Check out our current Career Opportunities. We’re always looking for like-minded engineers to join the BTI360 family.