Managing Complex Configuration Formats in Terraform: A Case for External Templates
When working with Terraform, most of our time is spent defining infrastructure using primitive types such as strings, booleans, and numbers, along with basic collections like lists and maps. These types are easy to work with and cover a wide range of use cases. However, there are times when we encounter more complex data requirements — particularly when dealing with configuration formats like JSON or XML.
This complexity often arises when provisioning cloud services that expect a string input formatted as structured data. Azure, for instance, has several resources that require embedded XML or JSON strings within a Terraform block. These strings may look like regular string attributes on the surface, but they’re tightly coupled to specific schemas or structure expectations.
The Problem with Inlining Structured Formats
Take the following example, where we define an Azure Application Insights Web Test:
resource "azurerm_application_insights_web_test" "liveness_test" {
name = "${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
application_insights_id = azurerm_application_insights.main.id
kind = "ping"
frequency = 300
timeout = 30
enabled = true
geo_locations = var.geo_locations
configuration = <<XML
<?xml version="1.0" encoding="UTF-8"?>
<WebTest xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010" Name="LivenessCheck" Id="${random_uid.webtest_id.result}" Enabled="True" Timeout="30" StopOnError="False" RecordResults="True">
<Items>
<Request Method="GET" Version="1.1" Url="http://${var.endpoint_url}" />
</Items>
</WebTest>
XML
}
This example demonstrates a common challenge: embedding complex XML directly in a Terraform configuration. Not only is the XML hard to read, but even minor modifications carry the risk of breaking the structure, especially without proper validation or syntax highlighting.
A Better Way: Externalizing Complex Data
A far more maintainable approach is to externalize this XML into its own file. This way, you can take advantage of better tooling — editors with XML support, version control diffs that are easier to read, and separation of concerns between infrastructure and data.
By convention, this file would be placed inside a files directory within the Terraform module: files/webtest.xml:
<?xml version="1.0" encoding="UTF-8"?>
<WebTest xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010" Name="LivenessCheck" Id="${random_uid.webtest_id.result}" Enabled="True" Timeout="30" StopOnError="False" RecordResults="True">
<Items>
<Request Method="GET" Version="1.1" Url="http://${var.endpoint_url}" />
</Items>
</WebTest>
To reference this file in Terraform, we might try using the file() function:
resource "azurerm_application_insights_web_test" "liveness_test" {
name = "${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
application_insights_id = azurerm_application_insights.main.id
kind = "ping"
frequency = 300
timeout = 30
enabled = true
geo_locations = var.geo_locations
configuration = file("./files/webtest.xml")
}
However, this has a limitation: static file inclusion means we can’t inject runtime values like ${random_uid.webtest_id.result} or ${var.endpoint_url}. In cases like this, we need something more flexible.
The Right Tool: templatefile()
Terraform offers the templatefile() function, which enables you to treat a file as a template and inject dynamic values into it. Here’s how we update our resource definition:
resource "azurerm_application_insights_web_test" "liveness_test" {
name = "${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
application_insights_id = azurerm_application_insights.main.id
kind = "ping"
frequency = 300
timeout = 30
enabled = true
geo_locations = var.geo_locations
configuration = templatefile("./files/webtest.xml",
{
"endpoint_url" = "${var.endpoint_url}",
"test_id" = "${random_uid.webtest_id.result}"
}
)
}
In the XML template file, we replace Terraform-style interpolations like ${var.endpoint_url} with the template variable syntax ${endpoint_url}. This keeps the XML clean and clearly separates structure from dynamic content.
<?xml version="1.0" encoding="UTF-8"?>
<WebTest xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010" Name="LivenessCheck" Id="${test_id}" Enabled="True" Timeout="30" StopOnError="False" RecordResults="True">
<Items>
<Request Method="GET" Version="1.1" Url="http://${endpoint_url}" />
</Items>
</WebTest>
Escaping Template Syntax Conflicts
It’s worth noting that some configuration formats, such as Grafana’s templating system, also use ${} syntax. If you’re working with a file that includes placeholders that shouldn’t be evaluated by Terraform, you can escape them using double dollar signs—e.g., $${placeholder}—so Terraform will ignore them and pass them through as-is. It’s kinda weird and kinda tedious, but it works.
Conclusion
Working with structured data formats like XML or JSON in Terraform can introduce complexity, but with the right techniques, this complexity becomes manageable. By externalizing structured content into separate files and leveraging Terraform’s templatefile() function (when appropriate), we achieve a cleaner, more maintainable configuration. This approach not only reduces the cognitive load when editing Terraform but also aligns with best practices in separating logic from data.
As with many things in infrastructure as code, the key is knowing when to lean into Terraform’s flexibility and how to do so in a way that keeps your codebase understandable and robust.