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 -
- What is Terraform template and what's the importance of Terraform template
- What is the extension by which you can save your Terraform template (.tftpl vs .tpl)
- Project structure and Terraform template location in the project?
- How to call a Terraform template inside your Terraform script (templatefile function)
- Passing lists and maps, for loops and if conditions inside a Terraform template
- Terraform template example - EC2 user_data with templatefile
- AWS Example using Terraform Template - Set up AWS IAM users and IAM role permissions for IAM user
- Common Terraform template errors and how to fix them
- What broke when I ran this
- Conclusion
TL;DR
- A Terraform template is a text file with
${placeholders}and%{ directives }thattemplatefile(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);.tplstill works, Terraform ignores the extension. - Call it with
templatefile("${path.module}/templates/x.tftpl", { key = value }). Alwayspath.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 injsonencode()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_filedata source is deprecated: thehashicorp/templateprovider's last release is v2.2.0 (2020) and it has nodarwin_arm64build, so it fails outright on Apple Silicon. Replacedata.template_file.x.renderedwithtemplatefile(). - 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 -
- Your name
- Street address
- Date
- Recipient Name
- Title

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 -

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?
- Readability - A 40 line shell script written as a Terraform string with
\nand escaped quotes is unreadable. The same script in a.tftplfile looks exactly like a shell script. - Reusability - One template, many resources. The same
user_data.tftplcan bootstrap your dev, staging and prod servers with different values. - Logic - A template can have
forloops andifconditions (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 -
.tftpl- This is the recommended extension since Terraform 1.0. Editors like VS Code (with the HashiCorp Terraform extension) and IntelliJ recognise.tftpland give you syntax highlighting for the template directives..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 -

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 theterraformcommand, which breaks as soon as you use the code as a module.path.moduleis always the directory of the current.tffile, 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 -
- The template file path
- 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 %{ -
| Directive | What 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 everyifline, because the newline after%{ for ... }or%{ if ... }is part of the template. Always use~}on yourfor,endfor,ifandendiflines, 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 write | Output for a two-item list | Why |
|---|---|---|
%{ for x in list } ... %{ endfor } | 4 lines, every second one blank | the 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} literally | escape for shell variables inside the template |
%%{ | %{ literally | escape 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_filesfrom my older version of this example. Let the AWS provider pick up credentials from your environment, your~/.aws/credentialsprofile 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

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


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



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 -
- IAM User
- 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

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

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

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 -
| Fact | Value | Source |
|---|---|---|
templatefile() available since | Terraform 0.12 (May 2019) | templatefile function docs |
.tftpl extension recommended since | Terraform 1.0 (June 2021) | templatefile function docs |
Last release of the template provider | v2.2.0, October 2020 (archived, no arm64 build) | Terraform Registry |
Arguments to templatefile() | 2 (path, vars map) | templatefile function docs |
| Variable types a template accepts | string, number, bool, list, map, object | string templates docs |
| Version used for every output in this post | Terraform v1.16.2 | terraform version |
Time to render the three templates in terraform console | 181 ms on my laptop | measured 2026-10-12 |
Time for terraform apply with only the four outputs | 216 ms | measured 2026-10-12 |
| EC2 user data size limit | 16 KB raw, before base64 encoding | EC2 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 -
- IAM User on AWS
- IAM policy for the user
- A reusable EC2 user_data bootstrap script
To summarise what I would recommend -
- Always use the built-in
templatefile()function, never the deprecatedtemplateprovider. - Save your templates with the
.tftplextension and keep them in atemplates/directory. - Use
${path.module}in the path. - 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 -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.
Read More - Terragrunt -