Strings & Templates
Strings are everywhere in Terraform configurations — bucket names, IAM policies, user-data scripts, container definitions, and tags. HCL gives you a small but powerful templating language built directly into string literals, so you can splice in variables, run conditionals, and loop over collections without leaving the string. Mastering interpolation, directives, heredocs, and the templatefile() function lets you generate everything from a one-line resource name to a multi-hundred-line cloud-init script while keeping the logic readable and terraform fmt-clean. Everything here applies equally to Terraform 1.5+ and OpenTofu, which share the HCL2 grammar.
String interpolation with ${...}
The most common templating tool is interpolation: anything inside ${ ... } is evaluated as an expression and its result is converted to a string and inserted in place.
variable "environment" {
type = string
default = "production"
}
resource "aws_s3_bucket" "logs" {
bucket = "devcraftly-${var.environment}-logs"
}
output "bucket_name" {
value = "Bucket is ${aws_s3_bucket.logs.bucket} in ${data.aws_region.current.name}"
}
The expression inside ${...} can be any valid HCL expression — a variable, a resource attribute, a function call, or arithmetic. If the result is not already a string (for example a number or boolean), Terraform converts it using its standard type-conversion rules.
Tip: Don’t wrap a single value in interpolation just to reference it. Write
bucket = aws_s3_bucket.logs.bucket, notbucket = "${aws_s3_bucket.logs.bucket}". The bare reference is clearer, andterraform fmtwill not flag the wrapped form for you — it is a common code-review nit.
Template directives: %{ if } and %{ for }
Beyond simple substitution, HCL supports directives using %{ ... }. These let you embed control flow — conditionals and loops — inside a string, which is invaluable when generating scripts or config files.
variable "enable_debug" {
type = bool
default = true
}
variable "ports" {
type = list(number)
default = [80, 443, 8080]
}
locals {
config = <<-EOT
log_level = %{ if var.enable_debug }debug%{ else }info%{ endif }
%{ for port in var.ports ~}
listen ${port}
%{ endfor ~}
EOT
}
output "rendered" {
value = local.config
}
Output:
rendered = <<EOT
log_level = debug
listen 80
listen 443
listen 8080
EOT
The ~ strip markers (%{ for ... ~} and ~}) trim the whitespace and newlines that the directive lines would otherwise introduce, keeping the rendered output tidy. Use them whenever a directive sits on its own line.
Heredocs for multiline strings
When a string spans multiple lines — an inline policy, a YAML snippet, or a shell script — a heredoc is far more readable than embedding \n escapes. A heredoc opens with << followed by a delimiter and ends when that delimiter appears alone on a line.
resource "aws_iam_role_policy" "s3_read" {
name = "s3-read"
role = aws_iam_role.app.id
policy = <<-EOT
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "${aws_s3_bucket.logs.arn}/*"
}
]
}
EOT
}
There are two forms. A plain <<EOT preserves the literal indentation of every line, which is rarely what you want inside nested blocks. The indented form <<-EOT allows the closing delimiter to be indented and strips the leading whitespace common to all lines, so the heredoc aligns naturally with your code. Interpolation and directives still work inside heredocs.
| Form | Closing delimiter | Leading indentation | When to use |
|---|---|---|---|
<<EOT | Must start at column 0 | Preserved exactly | Whitespace-sensitive payloads |
<<-EOT | May be indented | Common prefix stripped | Almost everything else (preferred) |
Warning: For structured data like JSON or YAML, prefer
jsonencode()andyamlencode()over hand-written heredocs. They guarantee valid syntax and correct escaping, so the IAM policy above is more safely written withjsonencode({ Version = "2012-10-17", Statement = [...] }).
The templatefile() function
When a template grows beyond a few lines, move it into its own file and render it with templatefile(path, vars). This keeps large scripts out of your .tf files and lets editors apply proper syntax highlighting. The template file uses the same ${...} and %{...} syntax, and the variables you pass become available by name.
Given a template templates/user-data.sh.tftpl:
#!/bin/bash
echo "Starting ${app_name} in ${environment}"
%{ for pkg in packages ~}
yum install -y ${pkg}
%{ endfor ~}
You render it inside a resource:
resource "aws_instance" "app" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
user_data = templatefile("${path.module}/templates/user-data.sh.tftpl", {
app_name = "devcraftly-api"
environment = var.environment
packages = ["nginx", "git", "docker"]
})
}
Output:
# aws_instance.app will be created
+ resource "aws_instance" "app" {
+ ami = "ami-0c02fb55956c7d316"
+ instance_type = "t3.micro"
+ user_data = "f3a1b9..." # rendered template hash
}
Plan: 1 to add, 0 to change, 0 to destroy.
The conventional extension is .tftpl. The second argument is a map whose keys are the only variables visible inside the template — the template cannot reach var.* or other Terraform symbols directly, which makes templates self-contained and easy to test.
Escaping
Because ${ and %{ are special, you must escape them when you want the literal characters — for example in a shell script that uses ${HOME} or in a Prometheus rule. Double the opening sequence: $${ renders a literal ${, and %%{ renders a literal %{.
locals {
script = "echo $${HOME} is the home dir, owned by ${var.username}"
}
Here $${HOME} is emitted verbatim as ${HOME} for the shell to expand at runtime, while ${var.username} is interpolated by Terraform at plan time.
Best Practices
- Use bare references (
var.region) instead of wrapping single values in"${var.region}". - Prefer indented heredocs (
<<-EOT) so templates align with surrounding code without leaking whitespace. - Reach for
jsonencode()andyamlencode()for structured payloads rather than hand-rolling JSON or YAML in a heredoc. - Move any template longer than a handful of lines into a
.tftplfile rendered withtemplatefile(). - Apply
~strip markers on directive lines to keep rendered output free of stray blank lines. - Escape literal
${and%{as$${and%%{when generating files that themselves use shell or template syntax.