Terraform templatefile with JSON and YAML

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
jq 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.

text
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 value

Each 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().

NOTE
Run terraform init in each new directory. The template file must already exist when Terraform begins evaluating configuration — 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:

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/basic-template/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/basic-template
bash
cat > greeting.tftpl <<'EOF'
Hello, ${name}!
Environment: ${environment}
EOF
bash
cat > variables.tf <<'EOF'
variable "name" {
  type    = string
  default = "Terraform lab"
}

variable "environment" {
  type    = string
  default = "dev"
}
EOF
bash
cat > 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
}
EOF

Initialize providers and plan:

bash
terraform init -input=false
bash
terraform plan -no-color -input=false
output
# 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.

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/pass-variables/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/pass-variables
bash
cat > app.tftpl <<'EOF'
application:
  name: ${app.name}
  version: ${app.version}
  owner: ${app.owner}
  tags:
%{ for k, v in app.tags ~}
    ${k}: ${v}
%{ endfor ~}
EOF
bash
cat > 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"
    }
  }
}
EOF
bash
cat > 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
}
EOF

Initialize and plan:

bash
terraform init -input=false
bash
terraform plan -no-color -input=false
output
# 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.

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safe/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safe
bash
cat > config.tftpl <<'EOF'
{
  "service": "${service_name}",
  "settings": ${jsonencode(settings)}
}
EOF

Note that settings is not quoted — jsonencode already returns a JSON object literal.

bash
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
}
EOF

When 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:

bash
terraform init -input=false
bash
terraform apply -auto-approve -input=false -no-color

Validate the template-rendered file parses as JSON:

bash
jq . ~/terraform-labs/terraform-templatefile-json-yaml/demos/jsonencode-safe/generated/config-from-template.json
output
{
  "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.

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/yamlencode-safe/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/yamlencode-safe
bash
cat > 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
}
EOF

Initialize and plan:

bash
terraform init -input=false
bash
terraform plan -no-color -input=false
output
# 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.

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/template-loop/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/template-loop
bash
cat > services.tftpl <<'EOF'
services:
%{ for svc in services ~}
  - name: ${svc.name}
    port: ${svc.port}
%{ endfor ~}
EOF
bash
cat > variables.tf <<'EOF'
variable "services" {
  type = list(object({
    name = string
    port = number
  }))
  default = [
    { name = "api", port = 8080 },
    { name = "metrics", port = 9090 },
  ]
}
EOF
bash
cat > 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
}
EOF

Initialize and plan:

bash
terraform init -input=false
bash
terraform plan -no-color -input=false
output
# 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:

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-output/generated
cd ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-output
bash
cat > 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
}
EOF

Initialize and apply:

bash
terraform init -input=false
bash
terraform apply -auto-approve -input=false -no-color

Parse the JSON file with jq:

bash
jq . ~/terraform-labs/terraform-templatefile-json-yaml/demos/validate-output/generated/config.json
output
{
  "app": "templatefile-lab",
  "features": [
    "json",
    "yaml",
    "validate"
  ],
  "version": "1.0.0"
}

Parse the YAML file with Terraform's yamldecode() in terraform console:

bash
echo 'yamldecode(file("generated/config.yaml"))' | terraform console -no-color
output
{
  "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:

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/string-required
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/string-required
bash
cat > bad.tftpl <<'EOF'
value: ${items}
EOF
bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

locals {
  items = ["a", "b"]
  bad   = templatefile("${path.module}/bad.tftpl", { items = local.items })
}
EOF
bash
terraform init -input=false

Validate fails immediately:

bash
terraform validate -no-color
output
Error: 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:

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/missing-template-var
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/missing-template-var
bash
cat > missing.tftpl <<'EOF'
Hello, ${name}! Missing: ${missing_var}
EOF
bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

locals {
  rendered = templatefile("${path.module}/missing.tftpl", {
    name = "lab"
  })
}
EOF
bash
terraform init -input=false
bash
terraform validate -no-color
output
Error: 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:

bash
mkdir -p ~/terraform-labs/terraform-templatefile-json-yaml/errors/double-encode
cd ~/terraform-labs/terraform-templatefile-json-yaml/errors/double-encode
bash
cat > 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
}
EOF
bash
terraform init -input=false
bash
terraform plan -no-color -input=false
output
+ 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


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.


Frequently Asked Questions

1. When should I use templatefile instead of jsonencode or yamlencode?

Use jsonencode or yamlencode when the output is a single structured document and you already hold the data in Terraform values. Use templatefile when you need mixed prose and data, provider-specific text formats, or comments and layout that encode functions cannot express. Inside a template that wraps JSON, still call jsonencode on nested objects rather than hand-escaping quotes.

2. Can I put a Terraform list or map directly into a template interpolation?

No. ${...} interpolation in a template file must produce a string. Lists, maps, and objects need jsonencode or yamlencode first, or a %{ for } loop that prints scalar fields line by line. Interpolating a list directly triggers string required, but have tuple.

3. Why does my generated YAML have quoted keys everywhere?

yamlencode emits YAML 1.2 style with quoted keys for compatibility. That is valid YAML even when it looks different from hand-written Kubernetes manifests. Parse it with yamldecode, a YAML loader, or kubectl apply --dry-run rather than judging by appearance alone.

4. What causes double-encoded JSON in Terraform output?

Calling jsonencode twice, or wrapping an already JSON-encoded string inside another jsonencode, stores JSON text as a quoted string value instead of a nested object. Encode the Terraform map or object once at the boundary where you need a string.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)