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.tfvars
Or 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 prod
There 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 apply
At first-glance you might be thinking
well hang on, issuing just one
terraform apply -var-file=terraform.prod.tfvars
command is quicker than issuing two commands,terraform workspace select prod
andterraform 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 apply
As 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=
.
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 terraform.tfvars
:
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_postgresql_flexible_server" "test_database" {
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 = azurerm_postgresql_flexible_server.test_database[0].id
}
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}-rg" # prod-rg, stg-rg, dev-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 = { for k, v in var.database_name[terraform.workspace] : k => v }
name = each.value.name
location = each.value.location
resource_group_name = "${terraform.workspace}-rg" # prod-rg, stg-rg, dev-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
}