Multiple Terraform CLI Workspaces Using Only One TFVars File
Terraform Cloud offers an excellent feature that allows you to set variables and override them with environment-specific variables. Unfortunately, replicating this behavior with the Terraform CLI requires some creativity.
Mimic The Gold Standard: Terraform Cloud Workspaces
Terraform Cloud Workspaces are wonderful. You can create variables in your Terraform code, and set default values for each environment.
var.server_name in the dev Terraform Cloud Workspace can be set to dev.my-server.my-company.com, and to prod.my-server.my-company.com in the prod Terraform Cloud Workspace.
It makes sense, right? Each environment overrides var.server_name as you would expect.
Terraform CLI Workspaces
Confusingly, Terraform CLI Workspaces behave nothing like Terraform Cloud Workspaces. The only thing they do is change a terraform.workspace variable to reflect the current workspace, and output to a different state file.
That's it.
Ugly Setups Of "One Workspace Per Environment"
Now Terraform will tell you to create separate *.tfvars files named by environment, so you can add the -var-file= flag to every command issued, yuck!
.
├──
│ main.tf
│ outputs.tf
│ providers.tf
│ terraform.dev.tfvars
│ terraform.prod.tfvars
│ terraform.stg.tfvars
│ variables.tf
$ terraform apply -var-file=terraform.prod.tfvarsOr even worse, you could copy your *.tf files into a separate folder for each environment where you have a duplicate or triplicate of everything, meaning one change has to be replicated to the other environments, good luck keeping that perfectly synchronized. Each providers.tf would have a different remote Terraform state file configured.
.
├── dev
│ ├── main.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── terraform.tfvars
│ ├── variables.tf
├── prod
│ ├── main.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── terraform.tfvars
│ ├── variables.tf
├── stg
│ ├── main.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── terraform.tfvars
│ ├── variables.tf
$ cd prod
$ terraform apply prodThere has to be a simple solution, right?
Terraform CLI, A Workspace Per Environment, One TFVars File
Ideally, you should have one copy of your code with each environment's variables overriding defaults. With this scheme, the remote Terraform state file will have the environment appended to it.
.
├──
│ main.tf
│ outputs.tf
│ providers.tf
│ terraform.tfvars
│ variables.tf
$ terraform workspace select prod
$ terraform applyAt first-glance you might be thinking
well hang on, issuing just one
terraform apply -var-file=terraform.prod.tfvarscommand is quicker than issuing two commands,terraform workspace select prodandterraform apply
And, well, you're kind of right, if, and only if you plan to apply once. Let's contrast the two:
$ terraform plan -var-file=terraform.prod.tfvars
$ terraform apply -var-file=terraform.prod.tfvars
$ terraform plan -var-file=terraform.prod.tfvars
$ terraform apply -var-file=terraform.prod.tfvars
$ terraform apply -var-file=terraform.prod.tfvars$ terraform workspace select dev
$ terraform plan
$ terraform apply
$ terraform plan
$ terraform apply
$ terraform applyAs you can see, in a practical example where you make changes, test their plan, apply approved changes, realize you need to tweak something, plan again, apply again, it fails to apply, and a second apply passes.
So yeah, in the real world it's faster to type a simple terraform plan or terraform apply command than it is to use -var-file=, especially if you set up bash aliases like alias tf='terraform', alias tfp='tf plan', and alias tfa='tf apply'.
Multiple Environments In Only terraform.tfvars
Normally, you would define a simple variable like so:
variable "database_name" {
type = string
description = "The name of the database"
default = "my_production_db_name"
}To support multiple environments, we consolidate all environment variables into one variables.tf (you can move default = {} values into terraform.tfvars instead):
variable "database_name" {
type = map(string)
description = "The name of the database"
default = {
prod = "my_production_db_name"
stg = "my_staging_db_name"
dev = "my_development_db_name"
}
}Accessing the correct variable is easy, simply pass in the current Terraform Workspace name as the map's key:
resource "azurerm_postgresql_flexible_server" "test_db" {
name = var.database_name[terraform.workspace]
}You can of course target a specific value if needed, like calling var.database_name["prod"] or var.database_name.prod.
Limiting resource creation to specific environments
Let's say you only want to create a resource in production, like a Container Registry, where other environments will share that same resource.
You can take advantage of count and testing the value of terraform.workspace:
resource "azurerm_container_registry" "my_acr" {
count = terraform.workspace == "prod" ? 1 : 0
name = var.database_name[terraform.workspace]
}Now, you'll need to access the correct index of azurerm_postgresql_flexible_server.test_db, which will always be [0] with this method
output "database_id" {
value = try(azurerm_container_registry.my_acr[0].id, null)
}We use try() because unless all environments create the resource, one or more environments will attempt to output a non-existing resource property, resulting in an error.
Another advantage is if you have a variable that requires iterating through with a for_each loop, you can simply leave some environments blank.
variable "database_names" {
type = map(list(object({
name = string
location = string
sku = string
})))
description = "The name of the database"
default = {
prod = [
{
name = "prod-primary_db-psql"
location = "eastus"
sku = "Standard"
},
{
name = "prod-secondary_db-psql"
location = "westus"
sku = "Standard"
}
]
stg = [] # No DB needed in Staging
dev = [
{
name = "dev-primary_db-psql"
location = "westeurope"
sku = "Standard"
},
]
}
}
resource "azurerm_postgresql_flexible_server" "test_database" {
for_each = toset(var.database_name[terraform.workspace])
name = each.value.name
location = each.value.location
resource_group_name = "${terraform.workspace}-databases-rg" # prod-databases-rg, dev-databases-rg
}Instead of using a list of object, we can use a map to give each database an easy-to-reuse key.
This makes it easier to precisely extract a specific piece of data from a potentially complicated dataset.
Here is the same example, slightly tweaked:
variable "database_names" {
type = map(map(map({
name = string
location = string
sku = string
})))
description = "The name of the database"
default = {
prod = { # Use curly braces
primary = {
name = "prod-primary_db-psql"
location = "eastus"
sku = "Standard"
} # Commas not needed
secondary_db = {
name = "prod-secondary_db-psql"
location = "westus"
sku = "Standard"
}
}
stg = {} # No DB needed in Staging
dev = {
primary = {
name = "dev-primary_db-psql"
location = "westeurope"
sku = "Standard"
}
}
}
}
resource "azurerm_postgresql_flexible_server" "test_database" {
for_each = var.database_name[terraform.workspace]
name = each.value.name
location = each.value.location
resource_group_name = "${terraform.workspace}-databases-rg" # prod-databases-rg, dev-databases-rg
}
output "primary_db_id" {
value = azurerm_postgresql_flexible_server.test_database["primary"].id
}
output "prod_db_name" {
value = var.database_names["prod"]["primary"].name
}