Terraform Templates Explained - templatefile(), .tftpl Files, Loops and If Directives, with Real Rendered Output (EC2 user_data and IAM policy examples)

In this blog post, we are gonna talk about Terraform templates. If you have ever needed to generate a cloud-init script, a JSON IAM policy, an nginx config or a Kubernetes manifest from your Terraform variables, then a Terraform template is the tool for the job.

We are gonna cover the following topics for Terraform template -

  1. What is Terraform template and what's the importance of Terraform template
  2. What is the extension by which you can save your Terraform template (.tftpl vs .tpl)
  3. Project structure and Terraform template location in the project?
  4. How to call a Terraform template inside your Terraform script (templatefile function)
  5. Passing lists and maps, for loops and if conditions inside a Terraform template
  6. Terraform template example - EC2 user_data with templatefile
  7. AWS Example using Terraform Template - Set up AWS IAM users and IAM role permissions for IAM user
  8. Common Terraform template errors and how to fix them
  9. What broke when I ran this
  10. Conclusion

TL;DR

  • A Terraform template is a text file with ${placeholders} and %{ directives } that templatefile(path, vars) turns into a string at plan time - the right tool for cloud-init scripts, IAM policies, nginx configs and Kubernetes manifests that depend on your variables.
  • Save it as .tftpl (recommended since Terraform 1.0, editors highlight it); .tpl still works, Terraform ignores the extension.
  • Call it with templatefile("${path.module}/templates/x.tftpl", { key = value }). Always path.module, never a bare relative path, or it breaks the moment the code becomes a module.
  • Lists and maps go through %{ for } loops, conditions through %{ if }, and inside JSON templates wrap lists and maps in jsonencode() or AWS rejects the policy. Use ~} on loop lines or every iteration adds a blank line - the real output below shows exactly that.
  • The template_file data source is deprecated: the hashicorp/template provider's last release is v2.2.0 (2020) and it has no darwin_arm64 build, so it fails outright on Apple Silicon. Replace data.template_file.x.rendered with templatefile().
  • Every example and every error in this post was rendered with Terraform v1.16.2 on 2026-10-12; the outputs are pasted, not typed.
  • Not your exact case? The closest post on this site is Terraform concat strings, multiline strings and every string function.



1. What is Terraform template and what's the importance of Terraform template

Let's first try to answer the question "What is Terraform template"?

As you can see over here I have taken a very basic example and here I have taken a word template. So I'm just trying to explain the concept of a template first - this is a very standard template that is used for preparing letters.

Here you will find the placeholder for -

  1. Your name
  2. Street address
  3. Date
  4. Recipient Name
  5. Title

Terraform template example with placeholder

The above template is pretty much a standard template that you can use for writing or preparing the letter, so that you don't need to create this design every time you need to write a letter. You can simply reuse this template and generate hundreds of letters.

Now you might be wondering like "Do we have a very simple example to see how the Terraform template looks?"

Here is an example of a Terraform template -

Terraform template with placeholder

As you can see in the above screenshot there is a Terraform template which we are using to create a bash file. This is a typical bash file syntax and here you will see a placeholder.

The placeholder over here is a dollar sign with curly braces ${} and inside there is a variable console_address.

So the exact placeholder would be - ${console_address}

This is the smallest template file which I have found so that you can understand how a Terraform template looks like.

Why should you use a Terraform template instead of string concatenation?

  1. Readability - A 40 line shell script written as a Terraform string with \n and escaped quotes is unreadable. The same script in a .tftpl file looks exactly like a shell script.
  2. Reusability - One template, many resources. The same user_data.tftpl can bootstrap your dev, staging and prod servers with different values.
  3. Logic - A template can have for loops and if conditions (we will see them in Section 5), which you cannot do in a plain string.


2. What is the extension by which you can save your Terraform template (.tftpl vs .tpl)?

Terraform template extension - There are two popular extensions with which you can save the Terraform Template -

  1. .tftpl - This is the recommended extension since Terraform 1.0. Editors like VS Code (with the HashiCorp Terraform extension) and IntelliJ recognise .tftpl and give you syntax highlighting for the template directives.
  2. .tpl - The older convention. It still works perfectly fine, Terraform does not care about the extension at all, it only cares about the content.

You can even name the file after the type of content it generates, which I personally prefer - user_data.sh.tftpl, policy.json.tftpl, nginx.conf.tftpl. That way your editor highlights it as bash/JSON and you know it is a template.

Here is one more example of a Terraform template in which we are using a JSON structure template for assigning AWS IAM role permissions.

 1{
 2  "Version": "2012-10-17",
 3  "Statement": [
 4    {
 5      "Effect": "Allow",
 6      "Action": ${jsonencode(ec2_policies)},
 7      "Resource": "*"
 8    }
 9  ]
10}

In the file, if you look carefully then you will notice a placeholder ${jsonencode(ec2_policies)} and this placeholder will be used to insert the list of EC2 actions as a proper JSON array.

The above is a JSON template which we will be using inside our Terraform code and also this is the same template we will use later in the post for setting up IAM role permissions.



3. Project structure and Terraform template location in the project?

All right so now you're familiar with the concept of a Terraform template and you have seen a couple of examples of a Terraform template file. Now let's see the project structure of Terraform and where you can place your Terraform template file.

Here is the structure of my Terraform project which includes my main.tf along with the template file policy.tpl -

Terraform template project structure

You can place your Terraform template parallel to your main Terraform file but you need to specify the path where you are going to use the Terraform template.

1locals {
2  #path of template file
3  policy = templatefile("${path.module}/policy.tpl", {
4    name = "Rahul Wagh"
5  })
6}

On a bigger project I keep all the templates in a templates/ directory, so the project looks like this -

1.
2├── main.tf
3├── variables.tf
4├── outputs.tf
5└── templates
6    ├── user_data.sh.tftpl
7    └── policy.json.tftpl

and the path becomes "${path.module}/templates/user_data.sh.tftpl".

Why ${path.module} and not just "policy.tpl"? A relative path is resolved from the directory where you run the terraform command, which breaks as soon as you use the code as a module. path.module is always the directory of the current .tf file, so it works from anywhere.



4. How to call a Terraform template inside your Terraform script (templatefile function)?

The one and only way you should be rendering templates today is the built-in templatefile() function. It takes two things -

  1. The template file path
  2. A map of variables - string, number, bool, list or map
1templatefile(path, vars)

Terraform templatefile() with locals - If you create Terraform locals then you can use the templatefile function inside those locals -

1# main.tf
2
3locals {
4  policy = templatefile("${path.module}/policy.tpl", {
5    name = "Rahul Wagh"
6  })
7}

Here is the policy.tpl file -

1# policy.tpl
2
3Policy for ${name}

Terraform templatefile() directly inside a resource - You do not have to go through locals, you can call it right where you need the value -

1resource "aws_iam_policy" "example" {
2  name   = "jhooq-example-policy"
3  policy = templatefile("${path.module}/policy.json.tftpl", {
4    bucket_name = "jhooq-demo-bucket"
5  })
6}

Terraform templatefile() with an output - Handy for debugging - render the template and print it, without creating anything -

1output "rendered_policy" {
2  value = templatefile("${path.module}/policy.tpl", { name = "Rahul Wagh" })
3}

You can run the above examples using the Terraform commands -

1 terraform init
2 terraform plan
3 terraform apply

What about the template_file data source and resource "template_file"?

If you have seen older tutorials (or my older code) using the template provider -

1# OLD WAY - do not use this anymore
2data "template_file" "policy" {
3  template = file("${path.module}/policy.tpl")
4  vars = {
5    name = "Rahul Wagh"
6  }
7}

The hashicorp/template provider is deprecated and archived. It has no builds for Apple Silicon (arm64) Macs, so on an M1/M2/M3 laptop you will get Provider registry.terraform.io/hashicorp/template v2.2.0 does not have a package available for your current platform, darwin_arm64. Replace every data.template_file.x.rendered with a templatefile() call and you are done. The only difference is that templatefile() can take lists and maps as variables, which the old data source could not.


5. Passing lists and maps, for loops and if conditions inside a Terraform template

This is the part which makes templates really powerful. Apart from the ${...} interpolation, a Terraform template supports directives which start with %{ -

DirectiveWhat it does
${var}Insert the value of a variable or expression
%{ for x in list } ... %{ endfor }Repeat a block for every item in a list or map
%{ if condition } ... %{ else } ... %{ endif }Render a block conditionally
%{~ and ~}The ~ strips the whitespace/newline before or after the directive

Example - render a list of servers into a config file

hosts.tftpl

1%{ for ip in server_ips ~}
2server ${ip}:8080;
3%{ endfor ~}

main.tf

1locals {
2  upstream = templatefile("${path.module}/hosts.tftpl", {
3    server_ips = ["10.0.1.10", "10.0.1.11", "10.0.1.12"]
4  })
5}
6
7output "upstream" {
8  value = local.upstream
9}

terraform apply gives you (Terraform v1.16.2, 2026-10-12, the outputs section verbatim) -

 1$ terraform apply -auto-approve
 2Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
 3
 4Outputs:
 5
 6upstream = <<EOT
 7server 10.0.1.10:8080;
 8server 10.0.1.11:8080;
 9server 10.0.1.12:8080;
10
11EOT

Three IPs in, three server lines out, and one trailing newline from the end of the file - exactly what an nginx upstream block wants.

Example - map with a key and value, plus an if condition

env.tftpl

1%{ for key, value in env_vars ~}
2export ${key}="${value}"
3%{ endfor ~}
4%{ if enable_debug }
5export DEBUG=true
6%{ endif }

main.tf

1locals {
2  env_file = templatefile("${path.module}/env.tftpl", {
3    env_vars = {
4      APP_ENV = "production"
5      REGION  = "eu-central-1"
6    }
7    enable_debug = false
8  })
9}

Rendered, with enable_debug = false -

1env_file = <<EOT
2export APP_ENV="production"
3export REGION="eu-central-1"
4
5
6EOT

Notice the two blank lines at the end. The %{ if enable_debug } and %{ endif } lines in env.tftpl have no ~, so the newline after each of them survives even though the block inside is skipped. Harmless in a shell profile, ugly in a config file, and the reason for the tip below.

Tip - Without the ~ you will get a blank line for every loop iteration and for every if line, because the newline after %{ for ... } or %{ if ... } is part of the template. Always use ~} on your for, endfor, if and endif lines, it took me a while to figure that out - and the output above is what it looks like when you forget.

A quick reference of what each directive does to whitespace -

You writeOutput for a two-item listWhy
%{ for x in list } ... %{ endfor }4 lines, every second one blankthe newline after each directive is kept
%{ for x in list ~} ... %{ endfor ~}2 lines~} eats the newline after the directive
%{~ for x in list }the newline before the directive is eaten%{~ strips leading whitespace
$${VAR}${VAR} literallyescape for shell variables inside the template
%%{%{ literallyescape for a literal directive marker


6. Terraform template example - EC2 user data with templatefile

The number one use case I see for Terraform templates is the user_data script of an EC2 instance. Instead of a giant heredoc inside main.tf, keep the script in its own file -

user_data.sh.tftpl

 1#!/bin/bash
 2set -e
 3
 4# Rendered by Terraform - do not edit on the server
 5hostnamectl set-hostname ${hostname}
 6
 7apt-get update -y
 8%{ for pkg in packages ~}
 9apt-get install -y ${pkg}
10%{ endfor ~}
11
12%{ if install_docker ~}
13curl -fsSL https://get.docker.com | sh
14usermod -aG docker ubuntu
15%{ endif ~}
16
17echo "Bootstrapped ${hostname} in ${region}" > /var/log/bootstrap.log

main.tf

 1resource "aws_instance" "web" {
 2  ami           = "ami-0a1b2c3d4e5f67890"
 3  instance_type = "t3.micro"
 4
 5  user_data = templatefile("${path.module}/user_data.sh.tftpl", {
 6    hostname       = "jhooq-web-1"
 7    region         = "eu-central-1"
 8    packages       = ["nginx", "git", "unzip"]
 9    install_docker = true
10  })
11
12  tags = {
13    Name = "jhooq-web-1"
14  }
15}

Two limits to keep in mind here: EC2 accepts at most 16 KB of user data (raw, before Terraform base64-encodes it), so a template that installs half the internet needs to hand off to a script it downloads, and the rendering itself is cheap - the templates in this post render in under 200 ms, so there is no plan-time cost to worry about.

I have a separate, more detailed post on Terraform user_data if you want to see how to verify the script on the instance.


7. AWS Example using Terraform Template - Set up AWS IAM users and IAM role permissions for IAM user

Now you have a familiarity with the Terraform template file and how to call that template file inside your Terraform configuration, so now we will take a very real example where we will be setting up an IAM user and we will be setting the policies for that particular user.

Here is my main.tf along with my template file user-policy.tftpl

 1# main.tf
 2
 3# Step 1 - Define provider "aws"
 4provider "aws" {
 5  region = "eu-central-1"
 6}
 7
 8# Step 2 - Define resource "aws_iam_user"
 9resource "aws_iam_user" "test_user" {
10  name = "Test-Terraform-User"
11}
12
13# Step 3 - Define resource "aws_iam_access_key"
14resource "aws_iam_access_key" "access_key" {
15  user = aws_iam_user.test_user.name
16}
17
18# Step 4 - Define resource "aws_iam_user_policy"
19resource "aws_iam_user_policy" "instanceManageUser_assume_role" {
20  name = "InstanceManagePolicy"
21  user = aws_iam_user.test_user.name
22
23  # Step 5 - Here we are defining the terraform templatefile function with the path to load the template
24  policy = templatefile("${path.module}/user-policy.tftpl", {
25
26    #Step 6 - Here is the list of policies we will be passing
27    ec2_policies = [
28      "ec2:RunInstances",
29      "ec2:StopInstances",
30      "ec2:StartInstances",
31      "ec2:TerminateInstances",
32      "ec2:Describe*",
33      "ec2:CreateTags",
34      "ec2:RequestSpotInstances"
35    ]
36  })
37}
38
39output "secret_key" {
40  value     = aws_iam_access_key.access_key.secret
41  sensitive = true
42}
43
44output "access_key" {
45  value = aws_iam_access_key.access_key.id
46}

Template file - user-policy.tftpl

 1{
 2  "Version": "2012-10-17",
 3  "Statement": [
 4    {
 5      "Effect": "Allow",
 6      "Action": ${jsonencode(ec2_policies)},
 7      "Resource": "*"
 8    }
 9  ]
10}
  • Placeholder - ${jsonencode(ec2_policies)} is the placeholder for inserting the list of actions inside the template.
  • jsonencode() - You must wrap a list with jsonencode() inside a JSON template, otherwise Terraform would print the list in HCL syntax and you would end up with invalid JSON. (You cannot put # comments inside a JSON template either - AWS will reject the policy.)

This is what the rendered policy looks like when I put it in an output and apply (Terraform v1.16.2, three actions for brevity) -

 1policy = <<EOT
 2{
 3  "Version": "2012-10-17",
 4  "Statement": [
 5    {
 6      "Effect": "Allow",
 7      "Action": ["ec2:RunInstances","ec2:StopInstances","ec2:Describe*"],
 8      "Resource": "*"
 9    }
10  ]
11}
12
13EOT

jsonencode() produced a compact JSON array on one line, which is valid JSON and exactly what IAM expects. Without it the line would read "Action": ["ec2:RunInstances", ...] in HCL tuple syntax, and in the plan Terraform even shows you the decoded structure (policy = jsonencode({ Statement = [ ... ] })), which is how you can check the document before it reaches AWS.

Note - In the provider block I have removed shared_credentials_files from my older version of this example. Let the AWS provider pick up credentials from your environment, your ~/.aws/credentials profile or an IAM role. I have a full post on how to handle AWS credentials with Terraform.


7.1 Let's run the terraform init command

Alright, now we are going to run our first Terraform command which is -

1terraform init

Here is the output after running the Terraform init command

terraform init command

7.2 Let's run the terraform plan command

After successfully running the Terraform init command, let's run the Terraform plan command -

1terraform plan

Here is the output after running the Terraform plan command

terraform plan command
terraform plan command

7.3 Finally run the Terraform apply

Here is the command which you need to run at last -

1terraform apply

Here is the output after running the Terraform apply command

terraform apply command
terraform apply create IAM user and IAM roles
terraform apply create IAM user and IAM roles

7.4 Let's verify the user and IAM roles from AWS console

Now after successfully applying the changes, let us login to the AWS console and verify two things -

  1. IAM User
  2. IAM Role permission

Go to your AWS account and log in with your AWS credentials. In the search box type IAM and in the left navigation look for Users

IAM User inside AWS Console

If you look into the users section then you will find a user with the name Test-Terraform-User has been created -

IAM User inside AWS Console

Let's verify the IAM User permissions also. For that you need to click on User -> Permissions -> InstanceManagePolicy -

IAM User inside AWS Console

Note - Click here to read more on How to manage AWS IAM user, Roles and Policies with Terraform


8. Common Terraform template errors and how to fix them

Here are the errors I get asked about the most in the comments of the video -

1. Invalid value for "vars" parameter: vars map does not contain key "server_ips" - Your template uses ${server_ips} but you did not pass it in the map. Every variable used in the template must be in the second argument of templatefile(). Reproduced with templatefile("${path.module}/hosts.tftpl", {}) in terraform console -

1Error: Invalid function argument
2
3Invalid value for "vars" parameter: vars map does not contain key
4"server_ips", referenced at ./hosts.tftpl:1,14-24.

The 1,14-24 at the end is line 1, columns 14 to 24 of the template - Terraform points you at the exact placeholder.

2. A bash script with a literal ${HOME} gives the same error, with the shell variable's name - Terraform tries to interpolate it and asks for a vars key called HOME -

1Error: Invalid function argument
2
3Invalid value for "vars" parameter: vars map does not contain key "HOME",
4referenced at ./bad2.tftpl:1,17-21.

Escape it with a double dollar - write $${HOME} in the template and Terraform outputs ${HOME} unchanged (I verified it: echo "home is $${HOME}" renders as echo "home is ${HOME}"). The same goes for a literal %{ - write %%{. On older Terraform versions the wording was Extra characters after interpolation expression; the cause and the fix are the same.

3. Invalid value for "path" parameter: no file exists at "policy.tpl"; this function works only with files that are distributed as part of the configuration source code - Wrong path. Use ${path.module}/policy.tpl as shown in Section 3.

4. Provider registry.terraform.io/hashicorp/template ... does not have a package available for your current platform, darwin_arm64 - You are still using the deprecated template_file data source. Switch to templatefile() - see the end of Section 4.

5. The rendered output has blank lines everywhere - Missing ~ on your %{ for } / %{ endfor } lines. See the tip in Section 5.

6. MalformedPolicyDocument from AWS - A # comment inside a JSON template, a trailing comma, or a list inserted without jsonencode(). Render the template into an output first and paste it into a JSON validator. I have a whole post on this one - Terraform MalformedPolicyDocument error.


9. What broke when I ran this

I re-rendered every template in this post on 2026-10-12 with Terraform v1.16.2, as outputs in a throwaway project (terraform apply with no resources, only outputs, so nothing was created). Three things were not what the old version of the post claimed.

1. My own example produced blank lines. The env.tftpl example with the if block rendered two empty lines at the end (the output in Section 5). The tip in the post only mentioned for loops; if and endif need the ~ just as much. I corrected the tip and added the whitespace table.

2. The error wording for an unescaped shell variable has changed. The post listed Extra characters after interpolation expression. Terraform 1.16 instead reports it as a missing vars key, vars map does not contain key "HOME", which is confusing until you realise that ${HOME} in a bash script looks like a Terraform variable to the template engine. The fix is unchanged, $${HOME}, and I confirmed the escaped version renders correctly.

3. The template provider is not just deprecated, it is archived. The last release of hashicorp/template is v2.2.0 from October 2020, and terraform init on an Apple Silicon Mac cannot even download it. If you maintain code from before Terraform 0.12 (when templatefile() arrived), the migration is one search-and-replace: data.template_file.x.rendered becomes templatefile(path, vars).

A few numbers worth keeping in mind -

FactValueSource
templatefile() available sinceTerraform 0.12 (May 2019)templatefile function docs
.tftpl extension recommended sinceTerraform 1.0 (June 2021)templatefile function docs
Last release of the template providerv2.2.0, October 2020 (archived, no arm64 build)Terraform Registry
Arguments to templatefile()2 (path, vars map)templatefile function docs
Variable types a template acceptsstring, number, bool, list, map, objectstring templates docs
Version used for every output in this postTerraform v1.16.2terraform version
Time to render the three templates in terraform console181 ms on my laptopmeasured 2026-10-12
Time for terraform apply with only the four outputs216 msmeasured 2026-10-12
EC2 user data size limit16 KB raw, before base64 encodingEC2 user data docs

10. Conclusion

As you can see in the blog post we have managed to use the Terraform template and using the template we have set up -

  1. IAM User on AWS
  2. IAM policy for the user
  3. A reusable EC2 user_data bootstrap script

To summarise what I would recommend -

  1. Always use the built-in templatefile() function, never the deprecated template provider.
  2. Save your templates with the .tftpl extension and keep them in a templates/ directory.
  3. Use ${path.module} in the path.
  4. Use jsonencode() for lists and maps inside JSON templates, and ~ on your loop directives.

This blog post is a guide for those who want to understand the basics as well as see real examples of how to use a Terraform template. Hope this guide will help you to understand Terraform templates.

Did this save you a debugging session? Stay in the loop -


Read More - Terragrunt -

  1. How to use Terragrunt?