| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1jq 1.8.1 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | Terraform templatefile with .tftpl files, passing template variables, jsonencode and yamlencode for safe JSON and YAML, template loops and conditionals, comparison with heredoc strings, jq and yamldecode validation, and common interpolation and encoding errors. Does not replace the full Terraform functions catalog. |
| Related guides | Terraform functions Terraform expressions Terraform variables try vs can functions Optional object attributes |
Hand-built JSON and YAML in Terraform break in predictable ways: a missed backslash around a quote, a list pasted where a scalar belongs, or YAML indentation that drifts one space when you add a nested map. templatefile() is the right tool when you need an external .tftpl file with ${name} placeholders and %{ for } loops. For structured data, HashiCorp recommends encoding Terraform values with jsonencode() or yamlencode() instead of stitching braces and indentation by hand.
templatefile(path, vars) → render a .tftpl file with ${var} and %{ for }
jsonencode(value) → valid JSON string from a Terraform value
yamlencode(value) → valid YAML string from a Terraform valueEach scenario uses its own directory under ~/terraform-labs/terraform-templatefile-json-yaml/. Examples write files with the local provider so you can inspect output on disk and validate with jq or yamldecode().
templatefile() does not participate in Terraform's dependency graph. For broader function coverage, see Terraform functions — this article focuses on generating JSON and YAML without syntax errors.
Basic templatefile example
templatefile() reads a file from disk and substitutes variables you pass in the second argument. Create the basic demo directory and template file:
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/basic-template/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/basic-templatecat > greeting.tftpl <<'EOF'
Hello, ${name}!
Environment: ${environment}
EOFcat > variables.tf <<'EOF'
variable "name" {
type = string
default = "Terraform lab"
}
variable "environment" {
type = string
default = "dev"
}
EOFcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
rendered = templatefile("${path.module}/greeting.tftpl", {
name = var.name
environment = var.environment
})
}
resource "local_file" "greeting" {
filename = "${path.module}/generated/greeting.txt"
content = local.rendered
}
EOFInitialize providers and plan:
terraform init -input=falseterraform plan -no-color -input=false# local_file.greeting will be created
+ resource "local_file" "greeting" {
+ content = <<-EOT
Hello, Terraform lab!
Environment: dev
EOT
+ filename = "./generated/greeting.txt"
}
Plan: 1 to add, 0 to change, 0 to destroy.The plan preview matches what lands in generated/greeting.txt after apply — two plain text lines with no JSON or YAML involved yet.
Pass variables into a template
The second argument to templatefile() is an object containing the variables exposed inside the template. Values can be strings, numbers, or nested objects and maps. The template reads object fields with dot notation and can loop over map entries with template directives.
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/pass-variables/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/pass-variablescat > app.tftpl <<'EOF'
application:
name: ${app.name}
version: ${app.version}
owner: ${app.owner}
tags:
%{ for k, v in app.tags ~}
${k}: ${v}
%{ endfor ~}
EOFcat > variables.tf <<'EOF'
variable "app" {
type = object({
name = string
version = string
owner = string
tags = map(string)
})
default = {
name = "billing-api"
version = "2.1.0"
owner = "platform"
tags = {
env = "staging"
team = "payments"
}
}
}
EOFcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
rendered = templatefile("${path.module}/app.tftpl", {
app = var.app
})
}
resource "local_file" "app_yaml" {
filename = "${path.module}/generated/app.yaml"
content = local.rendered
}
EOFInitialize and plan:
terraform init -input=falseterraform plan -no-color -input=false# local_file.app_yaml will be created
+ resource "local_file" "app_yaml" {
+ content = <<-EOT
application:
name: billing-api
version: 2.1.0
owner: platform
tags:
env: staging
team: payments
EOT
+ filename = "./generated/app.yaml"
}
Plan: 1 to add, 0 to change, 0 to destroy.The %{ for k, v in app.tags ~} loop prints each tag on its own indented line. Scalar fields use ${app.name} style interpolation. This works for flat maps; nested lists of objects are safer with yamlencode() — covered next.
Generate JSON safely with jsonencode
When a template wraps JSON, embed encoded structures instead of escaping quotes manually. One missed \" produces invalid JSON that plan may still accept.
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safe/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safecat > config.tftpl <<'EOF'
{
"service": "${service_name}",
"settings": ${jsonencode(settings)}
}
EOFNote that settings is not quoted — jsonencode already returns a JSON object literal.
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
settings = {
retries = 3
note = "He said \"retry\" twice"
hosts = ["api.internal", "api-backup.internal"]
}
from_template = templatefile("${path.module}/config.tftpl", {
service_name = "orders-api"
settings = local.settings
})
from_hcl = jsonencode({
service = "orders-api"
settings = local.settings
})
}
resource "local_file" "config_from_template" {
filename = "${path.module}/generated/config-from-template.json"
content = local.from_template
}
resource "local_file" "config_from_hcl" {
filename = "${path.module}/generated/config-from-hcl.json"
content = local.from_hcl
}
EOFWhen the entire output is JSON with no surrounding template prose, skip the .tftpl file and call jsonencode() directly in HCL — the lab produces identical files both ways.
Initialize and apply:
terraform init -input=falseterraform apply -auto-approve -input=false -no-colorValidate the template-rendered file parses as JSON:
jq . ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safe/generated/config-from-template.json{
"service": "orders-api",
"settings": {
"hosts": [
"api.internal",
"api-backup.internal"
],
"note": "He said \"retry\" twice",
"retries": 3
}
}jq exits zero, which confirms quotes inside note were encoded correctly without manual escaping.
Generate YAML safely with yamlencode
Hand-indenting YAML around interpolated maps fails when nesting deepens or a value contains characters YAML treats specially. yamlencode() turns any Terraform value into valid YAML text.
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/yamlencode-safe/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/yamlencode-safecat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
stack = {
name = "web"
labels = {
app = "nginx"
env = "lab"
}
}
safe_yaml = yamlencode({
apiVersion = "v1"
kind = "ConfigMap"
metadata = {
name = local.stack.name
}
data = local.stack.labels
})
heredoc_yaml = <<-EOT
apiVersion: v1
kind: ConfigMap
metadata:
name: ${local.stack.name}
data:
app: ${local.stack.labels.app}
env: ${local.stack.labels.env}
EOT
}
resource "local_file" "safe_yaml" {
filename = "${path.module}/generated/safe.yaml"
content = local.safe_yaml
}
resource "local_file" "heredoc_yaml" {
filename = "${path.module}/generated/heredoc.yaml"
content = local.heredoc_yaml
}
EOFInitialize and plan:
terraform init -input=falseterraform plan -no-color -input=false# local_file.safe_yaml will be created
+ resource "local_file" "safe_yaml" {
+ content = <<-EOT
"apiVersion": "v1"
"data":
"app": "nginx"
"env": "lab"
"kind": "ConfigMap"
"metadata":
"name": "web"
EOT
+ filename = "./generated/safe.yaml"
}
# local_file.heredoc_yaml will be created
+ resource "local_file" "heredoc_yaml" {
+ content = <<-EOT
apiVersion: v1
kind: ConfigMap
metadata:
name: web
data:
app: nginx
env: lab
EOT
+ filename = "./generated/heredoc.yaml"
}
Plan: 2 to add, 0 to change, 0 to destroy.yamlencode quotes keys and may reorder fields — that is still valid YAML. The heredoc version looks closer to a handwritten manifest but does not scale when data becomes a dynamic map. Prefer yamlencode(local.data) for nested structures and reserve heredoc for static skeletons with few interpolations.
Loops and conditionals in templates
Template directives use %{ for }, %{ if }, and %{ endif } for text-oriented output. Use them when layout matters — bullet lists, comment headers, or provider config that is not pure JSON or YAML.
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/template-loop/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/template-loopcat > services.tftpl <<'EOF'
services:
%{ for svc in services ~}
- name: ${svc.name}
port: ${svc.port}
%{ endfor ~}
EOFcat > variables.tf <<'EOF'
variable "services" {
type = list(object({
name = string
port = number
}))
default = [
{ name = "api", port = 8080 },
{ name = "metrics", port = 9090 },
]
}
EOFcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
template_body = templatefile("${path.module}/services.tftpl", {
services = var.services
})
encoded_body = yamlencode({
services = var.services
})
}
resource "local_file" "template_body" {
filename = "${path.module}/generated/services-template.txt"
content = local.template_body
}
resource "local_file" "encoded_body" {
filename = "${path.module}/generated/services-encoded.yaml"
content = local.encoded_body
}
EOFInitialize and plan:
terraform init -input=falseterraform plan -no-color -input=false# local_file.template_body will be created
+ resource "local_file" "template_body" {
+ content = <<-EOT
services:
- name: api
port: 8080
- name: metrics
port: 9090
EOT
+ filename = "./generated/services-template.txt"
}
# local_file.encoded_body will be created
+ resource "local_file" "encoded_body" {
+ content = <<-EOT
"services":
- "name": "api"
"port": 8080
- "name": "metrics"
"port": 9090
EOT
+ filename = "./generated/services-encoded.yaml"
}
Plan: 2 to add, 0 to change, 0 to destroy.Reach for %{ for } when consumers expect a specific text shape. Reach for yamlencode({ services = var.services }) when any valid YAML document is enough.
Compare heredoc, templatefile, and encode functions
| Approach | Best for | Structured JSON/YAML risk |
|---|---|---|
Heredoc (<<-EOT) |
Short static files with a few ${local.x} substitutions |
High when nesting maps or lists — indentation is manual |
templatefile() |
External templates, loops, conditionals, mixed prose and data | Medium — safe when you call jsonencode / yamlencode inside the template for nested values |
jsonencode() / yamlencode() |
Entire document is one Terraform object | Low — Terraform handles quoting and structure |
A practical rule: build data in locals or variables, encode once at the output boundary, and use templatefile only for the wrapper text around encoded blocks.
Template expressions can call Terraform functions such as jsonencode() and yamlencode(), but recursive calls to templatefile() are not permitted. If you need dynamically sourced template text rather than a static template file, use templatestring() where appropriate.
Validate JSON and YAML output
After apply, confirm files parse before another tool consumes them. The validate-output demo writes both formats from the same local.data map:
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-output/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-outputcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
locals {
data = {
app = "templatefile-lab"
version = "1.0.0"
features = ["json", "yaml", "validate"]
}
config_json = jsonencode(local.data)
config_yaml = yamlencode(local.data)
}
resource "local_file" "config_json" {
filename = "${path.module}/generated/config.json"
content = local.config_json
}
resource "local_file" "config_yaml" {
filename = "${path.module}/generated/config.yaml"
content = local.config_yaml
}
EOFInitialize and apply:
terraform init -input=falseterraform apply -auto-approve -input=false -no-colorParse the JSON file with jq:
jq . ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-output/generated/config.json{
"app": "templatefile-lab",
"features": [
"json",
"yaml",
"validate"
],
"version": "1.0.0"
}Parse the YAML file with Terraform's yamldecode() in terraform console:
echo 'yamldecode(file("generated/config.yaml"))' | terraform console -no-color{
"app" = "templatefile-lab"
"features" = [
"json",
"yaml",
"validate",
]
"version" = "1.0.0"
}Both parsers return the same logical data — a quick gate before you commit generated config to git or hand it to CI.
Common templatefile errors
| Symptom | Likely cause | Fix |
|---|---|---|
string required, but have tuple (or have map) |
${items} interpolates a list or map directly |
Use %{ for } for text lines, or jsonencode(items) / yamlencode(items) for structured output |
vars map does not contain key "missing_var" |
Template references ${missing_var} but the vars object omits it |
Add the key to the second templatefile() argument or remove the reference from the .tftpl file |
| Valid plan but broken JSON at runtime | Manual quote escaping in the template | Replace hand-built JSON with ${jsonencode(settings)} |
| YAML indentation errors after adding a nested field | Heredoc or loop body indentation drift | Switch nested sections to yamlencode() |
| JSON object stored as an escaped string | jsonencode(jsonencode(...)) or encoding already-encoded text |
Encode the Terraform value once; nest objects in HCL, not as pre-encoded strings |
Literal %{ is interpreted as a template directive |
Template text contains the reserved %{ sequence |
Write %%{ to emit a literal %{ |
string required on a list
bad.tftpl contains value: ${items} where items is a list:
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/string-required
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/string-requiredcat > bad.tftpl <<'EOF'
value: ${items}
EOFcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
items = ["a", "b"]
bad = templatefile("${path.module}/bad.tftpl", { items = local.items })
}
EOFterraform init -input=falseValidate fails immediately:
terraform validate -no-colorError: Error in function call
on main.tf line 7, in locals:
7: bad = templatefile("${path.module}/bad.tftpl", { items = local.items })
Call to function "templatefile" failed: ./bad.tftpl:1,10-15: Invalid template
interpolation value; Cannot include the given value in a string template:
string required, but have tuple.Terraform refuses to stringify the whole list inside ${} — encode or loop instead.
Missing template variable
missing.tftpl references ${missing_var} but the vars object only passes name:
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/missing-template-var
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/missing-template-varcat > missing.tftpl <<'EOF'
Hello, ${name}! Missing: ${missing_var}
EOFcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
rendered = templatefile("${path.module}/missing.tftpl", {
name = "lab"
})
}
EOFterraform init -input=falseterraform validate -no-colorError: Invalid function argument
on main.tf line 6, in locals:
6: rendered = templatefile("${path.module}/missing.tftpl", {
Invalid value for "vars" parameter: vars map does not contain key
"missing_var", referenced at ./missing.tftpl:1,28-39.Every ${key} in the template needs a matching entry in the vars object.
Double encoding
Calling jsonencode(jsonencode(local.data)) stores JSON text as a quoted string:
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/double-encode
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/double-encodecat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
data = {
host = "db.internal"
port = 5432
}
double_json = jsonencode(jsonencode(local.data))
}
output "double_json" {
value = local.double_json
}
EOFterraform init -input=falseterraform plan -no-color -input=false+ double_json = "\"{\\\"host\\\":\\\"db.internal\\\",\\\"port\\\":5432}\""The inner JSON became a quoted string instead of a nested object — decode once on the consumer side or remove the extra jsonencode call.
References
- templatefile function — HashiCorp Terraform language docs
- jsonencode function — HashiCorp Terraform language docs
- yamlencode function — HashiCorp Terraform language docs
- yamldecode function — HashiCorp Terraform language docs
- Template syntax — HashiCorp Terraform language docs
Summary
You walked through templatefile() from a basic ${name} greeting through objects and %{ for } loops that expand tags and service lists. The pattern that prevents most production pain is simple: keep structured data in Terraform values, then call jsonencode() or yamlencode() at the point where text meets the file — either inside the .tftpl or directly in HCL when no template wrapper is needed.
Hand-built quoting and heredoc indentation look fine until a nested map or a string with embedded quotes appears. The lab validated generated files with jq and yamldecode() so you can catch syntax errors before downstream tools do. When plan fails with string required, but have tuple, the template tried to interpolate a collection where only a string fits — switch to an encode function or a for loop.
For mixed prose and data, keep templatefile. For whole JSON or YAML documents, prefer jsonencode and yamlencode alone. If you normalize messy external input before encoding, see try vs can functions for safe attribute access on decoded objects.

