Terraform Template File
Terraform has a handy way to render configuration files dynamically by injecting variables into templates. If you have ever needed to generate a user-data script, a config file, or a YAML manifest from within Terraform, the templatefile() function is the tool for the job.
The templatefile() function reads a template file and substitutes variables into it. I use it all over my Terraform projects, including inside Modules.
A Quick Note on the Old template_file Data Source
You might still find examples online that use data "template_file". That came from the hashicorp/template provider, which has been archived and is no longer maintained. The built-in templatefile() function replaced it starting with Terraform 0.12, and it is what you should use today. (Terraform 1.9 also added templatestring() for rendering templates from a string variable rather than a file.)
I will show both the old and new approaches in the examples below so you can migrate if you need to.
Basic Example: User Data with Variable Substitution
Creating User Data with Terraform
Let’s say you are launching an EC2 instance and you need to install Docker and Ansible on startup. You would pass a bash script through the user_data field. But you want the package versions to be configurable.
Here is the template file (userdata.tftpl):
#!/bin/bash -xe
yum update -y
yum install -y docker-${DOCKER_VERSION}
yum install -y ansible-${ANSIBLE_VERSION}
yum install -y zip unzip
Note: If you are using Amazon Linux 2023, amazon-linux-extras is no longer available. Use dnf install instead.
Using the templatefile() Function
locals {
userdata = templatefile("${path.module}/userdata.tftpl", {
DOCKER_VERSION = var.DOCKER_VERSION
ANSIBLE_VERSION = var.ANSIBLE_VERSION
})
}
You can then reference it as local.userdata in your resource.
Old Approach (Deprecated)
For reference, here is what it looked like with the old template_file data source:
data "template_file" "userdata" {
template = file("${path.module}/userdata.sh")
vars = {
DOCKER_VERSION = var.DOCKER_VERSION
ANSIBLE_VERSION = var.ANSIBLE_VERSION
}
}
Passing the Rendered Template to a Launch Template
Here I am using aws_launch_template, which is the current standard. The older aws_launch_configuration resource is tied to EC2-Classic and has limitations.
resource "aws_launch_template" "bitslovers-lt" {
name_prefix = "${var.my_environment_name}-lt-"
image_id = "ami-12345"
instance_type = var.INSTANCE_TYPE
vpc_security_group_ids = [aws_security_group.inst.id]
user_data = base64encode(local.userdata)
key_name = "bitslovers-keypair"
iam_instance_profile {
name = aws_iam_instance_profile.iam_profile.name
}
}
Note: aws_launch_template requires user_data to be base64-encoded, so I wrapped it with base64encode().
For Loops in Templates
You can iterate over a list inside a template using the %{ for ... } directive. This is useful when you need to generate repeated blocks of configuration.
Example: DNSMasq Configuration
Suppose you need to configure DNSMasq with a list of DNS server IPs. The final config file should look like this:
#DNSMasq Server Configuration
bind-interfaces
user=dnsmasq
group=dnsmasq
pid-file=/var/run/dnsmasq.pid
cache-size=1000
max-cache-ttl=300
neg-ttl=60
strict-order
bogus-priv
no-resolv
expand-hosts
server=/www.bitslovers.com/10.180.1.2
server=/www.bitslovers.com/10.170.1.2
server=/www.bitslovers.com/10.160.1.2
server=8.8.8.8
no-dhcp-interface=
Here is how you generate that file dynamically with Terraform:
resource "local_file" "dnsmasq_conf" {
filename = "${path.module}/dnsmasq-dynamic.conf"
file_permission = "0666"
content = <<-EOT
bind-interfaces
user=dnsmasq
group=dnsmasq
pid-file=/var/run/dnsmasq.pid
cache-size=1000
max-cache-ttl=300
neg-ttl=60
strict-order
bogus-priv
no-resolv
expand-hosts
%{ for ip in var.dns_servers }
server=/www.bitslovers.com/${ip}
%{ endfor }
server=8.8.8.8
no-dhcp-interface=
EOT
}
Define the variable as a list of strings:
variable "dns_servers" {
type = list(string)
description = "Private IP DNS"
}
Then pass in the values:
dns_servers = ["10.180.1.2", "10.170.1.2", "10.160.1.2"]
Terraform renders the template and writes the file with the correct server lines.
Generating JSON with jsonencode
Writing valid JSON by hand inside a template is error-prone. Terraform has a built-in jsonencode() function that handles all the quoting and escaping for you.
locals {
sg_cidr_config_json = jsonencode({
SG_CIDR = [for addr in var.ip_address : "${addr}/${var.subnet}"]
})
}
Given ip_address = ["10.180.0.0", "10.170.0.0"] and subnet = "24", the result is:
{"SG_CIDR":["10.180.0.0/24","10.170.0.0/24"]}
No manual escaping needed.
Generating YAML with yamlencode
The same approach works for YAML. Terraform provides yamlencode():
locals {
sg_cidr_config_yaml = yamlencode({
SG_CIDR = [for addr in var.ip_address : "${addr}/${var.subnet}"]
})
}
You can read more about this in the Locals guide.
Conditionals in Templates
IF Statements
Sometimes you need to include or exclude parts of a template based on a condition. Terraform templates support %{ if ... } / %{ endif } directives.
A common use case is the aws-auth ConfigMap for an EKS cluster. You might want to conditionally include extra IAM roles or users.
Here is the template file (auth-configmap.yaml.tftpl):
apiVersion: v1
kind: ConfigMap
metadata:
name: aws-auth
namespace: kube-system
data:
mapRoles: |
${indent(4, map_ec2_worker_roles_yaml)}
%{ if map_extra_iam_roles_yaml != "[]" }
${indent(4, map_extra_iam_roles_yaml)}
%{ endif }
%{ if map_additional_iam_users_yaml != "[]" }
mapUsers: |
${indent(4, map_additional_iam_users_yaml)}
%{ endif }
The %{ if ... } blocks check whether the lists are empty. If map_extra_iam_roles_yaml is "[]", that section is skipped entirely.
Define your locals:
locals {
auth_configmap_template = "${path.module}/auth-configmap.yaml.tftpl"
map_ec2_worker_roles_yaml = trimspace(yamlencode(local.map_worker_roles))
map_extra_iam_roles_yaml = trimspace(yamlencode(var.map_additional_iam_roles))
map_additional_iam_users_yaml = trimspace(yamlencode(var.map_additional_iam_users))
}
Then render it with templatefile():
locals {
auth_configmap = templatefile(local.auth_configmap_template, {
map_ec2_worker_roles_yaml = local.map_ec2_worker_roles_yaml
map_extra_iam_roles_yaml = local.map_extra_iam_roles_yaml
map_additional_iam_users_yaml = local.map_additional_iam_users_yaml
})
}
Wrapping Up
Terraform templates are one of those features I rely on constantly. They let you keep configuration files readable while still making them dynamic. The templatefile() function handles the common case, jsonencode()/yamlencode() handle structured data, and the %{ if } / %{ for } directives give you control over what gets rendered.
Check also how to run Terraform on Gitlab CI with Plan, Apply and Destroy stages.
Boost your Terraform Skills:
How and When to use Terraform Modules?
Do you know what is Terraform Data and How to use it?
Learn How to use Output on your Terraform and share data across multiple configurations.
Create multiple copies of the same resource using Terraform Count.
Execute Terraform on Gitlab CI.
Check also other articles related to DevOps:
How to use the Gitlab CI Variables
Effective Cache Management with Maven Projects on Gitlab.
Pipeline to build Docker in Docker on Gitlab.
How to Autoscaling the Gitlab Runner.
Comments