Terraform Strings: The Complete Guide
Master Terraform strings: learn advanced templating, heredocs, and built-in functions for cleaner, reusable HCL code in minutes.
String Basics: Defining and Quoting
In Terraform, strings represent text sequences used throughout your configurations. You'll use strings constantly: naming resources, setting tags, writing user data scripts, and defining outputs. The HashiCorp Configuration Language (HCL) provides flexible ways to define and manipulate these strings.
Quoted String Literals
The most straightforward way to define strings is using double quotes (").
variable "instance_name_prefix" {
type = string
default = "my-app-server"
description = "The prefix for server names."
}
output "greeting_message" {
value = "Hello, Terraform World!"
}
Quoted strings work well for single-line text and simple values. However, they become unwieldy when you need multiline content or complex text blocks.
Heredoc String Literals
When you need multiline strings like shell scripts, JSON policies, or YAML configurations, heredocs provide a cleaner syntax inspired by Unix shells.
Syntax:
<<DELIMITER
content here
DELIMITER
Indented Heredocs (<<-DELIMITER)
This is the preferred form for cleaner HCL code. The indented heredoc strips common leading whitespace from all lines, keeping your code readable without affecting the string content.
resource "aws_instance" "web_server" {
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
echo "Nginx installed" > /tmp/status.txt
EOF
}
The leading indentation in the HCL code isn't included in the actual script—a huge win for readability.
Standard Heredocs (<<DELIMITER)
Standard heredocs preserve all leading whitespace. Use these only when you specifically need indentation in the output.
resource "local_file" "standard_heredoc" {
filename = "${path.module}/standard.txt"
content = <<EOF
This line is indented in the HCL.
And this line will have its indentation preserved.
EOF
}
Choosing Delimiters
While EOF is conventional, you can use any valid identifier. This is helpful if your content contains the word "EOF":
content = <<-ENDSCRIPT
# Script content containing "EOF"
ENDSCRIPT
Escape Sequences and Special Characters
When your string needs to include special characters, use escape sequences starting with a backslash ().
Common Escape Sequences
| Sequence | Meaning |
|---|---|
\n |
Newline |
\r |
Carriage return |
\t |
Tab |
\" |
Literal double quote |
\\ |
Literal backslash |
Example: Complex Messages
locals {
complex_message = "He said, \"Terraform is awesome!\"\nThis is on a new line."
}
# Renders as:
# He said, "Terraform is awesome!"
# This is on a new line.
Escaping Interpolation
To include literal ${ in your string (often needed for shell variables or templates), use $${:
resource "local_file" "shell_script" {
filename = "${path.module}/env_script.sh"
content = <<-EOF
#!/bin/bash
# This is interpolated by Terraform:
echo "AWS Region: ${var.aws_region}"
# This becomes a literal shell variable:
echo "Current user: $${USER}"
echo "Home: $${HOME}/data"
EOF
}
In the resulting script, $${USER} becomes ${USER}, which the shell interprets at runtime.
String Interpolation and Directives
String interpolation makes your strings dynamic by embedding Terraform expressions directly within them.
Basic Interpolation Syntax
The syntax is straightforward: ${expression}. Terraform evaluates the expression and inserts the result into the string.
variable "environment" {
type = string
default = "dev"
}
output "instance_name" {
value = "app-server-${var.environment}-01"
}
# Output: "app-server-dev-01"
What You Can Interpolate
Input Variables:
variable "aws_region" {
type = string
default = "us-east-1"
}
output "selected_region" {
value = "Deploying to the ${var.aws_region} region."
}
Resource Attributes:
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
}
output "instance_id_info" {
value = "The new instance ID is: ${aws_instance.example.id}"
}
List/Map Elements:
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "server_config" {
type = map(string)
default = {
"cpu" = "2"
"ram" = "4GB"
}
}
output "primary_az" {
value = "Primary AZ: ${var.availability_zones[0]}"
}
output "server_ram" {
value = "Server RAM: ${var.server_config["ram"]}"
}
Loop Variables (count.index, each.key, each.value):
variable "user_vms" {
type = map(string)
default = {
"alice" = "t2.small"
"bob" = "t2.medium"
}
}
resource "aws_instance" "user_specific_vms" {
for_each = var.user_vms
ami = "ami-0c55b159cbfafe1f0"
instance_type = each.value
tags = {
Name = "vm-for-${each.key}"
Owner = each.key
}
}
Function Results:
output "module_path" {
value = "This module is located at: ${path.module}"
}
Basic Arithmetic:
variable "base_app_port" {
type = number
default = 8080
}
output "app_ports" {
value = "App 1: ${var.base_app_port}, App 2: ${var.base_app_port + 1}"
}
Heredocs and Multiline Strings
Heredocs shine when managing complex multiline content. Combined with string interpolation and template directives, they're powerful for generating configuration files and scripts.
Indented Heredoc Best Practices
resource "aws_instance" "web_server" {
# Clean indentation in HCL source code
user_data = <<-EOF
#!/bin/bash
set -e
# Update system
apt-get update
apt-get upgrade -y
# Install dependencies
apt-get install -y \
nginx \
curl \
wget
# Start services
systemctl enable nginx
systemctl start nginx
echo "Initialization complete at $(date)" >> /var/log/user-data.log
EOF
}
The <<- variant is almost always preferable to << because it maintains code readability without affecting the output.
Combining Heredocs with Interpolation
variable "environment" {
type = string
default = "staging"
}
variable "app_port" {
type = number
default = 8080
}
resource "aws_instance" "app_server" {
user_data = <<-EOF
#!/bin/bash
ENVIRONMENT="${var.environment}"
PORT="${var.app_port}"
echo "Deploying to $${ENVIRONMENT} on port $${PORT}"
# Application startup logic here
EOF
}
String Functions
Terraform provides a rich set of built-in functions for string manipulation. These work within interpolations, local values, outputs, and function calls.
String Transformation Functions
format(spec, values...)
Creates a formatted string using printf-style syntax:
> format("Server: %s, IP: %s, Cores: %d", "web01", "10.0.1.5", 4)
"Server: web01, IP: 10.0.1.5, Cores: 4"
locals {
instance_info = format(
"Instance %s (%s) in %s",
var.instance_name,
var.instance_type,
var.aws_region
)
}
join(separator, list) and split(separator, string)
Join combines list elements with a separator; split does the inverse:
> join("-", ["app", "prod", "web", "01"])
"app-prod-web-01"
> split(".", "www.example.com")
["www", "example", "com"]
locals {
resource_name = join("-", [var.project, var.environment, var.resource_type])
name_parts = split("-", local.resource_name)
}
replace(string, search, replacement)
Replaces occurrences of a substring (supports regex):
> replace("my_app_v1.0", "_", "-")
"my-app-v1.0"
> replace("user_id_123", "/(\\d+)$/", "-num-$1")
"user-num-123"
locals {
sanitized_name = replace(var.user_input, "/[^a-z0-9-]/", "")
}
lower(string) and upper(string)
Convert case—essential for resources with case-sensitive naming (e.g., S3 buckets):
> lower("MyAwesomeBucket")
"myawesomebucket"
> upper("dev-instance")
"DEV-INSTANCE"
locals {
bucket_name = lower("${var.company}-${var.project}-${data.aws_caller_identity.current.account_id}")
}
substr(string, offset, length)
Extract substrings (0-indexed):
> substr("abcdefgh", 2, 3)
"cde"
> substr("HelloTerraform", 0, 5)
"Hello"
locals {
env_short = lower(substr(var.environment, 0, 3)) # "production" -> "pro"
}
Whitespace Trimming Functions
chomp(string)
Removes trailing newlines (\n, \r\n):
> chomp("some text\n")
"some text"
locals {
clean_content = chomp(file("${path.module}/config.txt"))
}
trimsuffix(string, suffix) and trimprefix(string, prefix)
Remove specific suffixes or prefixes:
> trimsuffix("backup_final.zip", ".zip")
"backup_final"
> trimprefix("project-app-server", "project-")
"app-server"
locals {
domain_without_extension = trimsuffix(var.domain, ".com")
service_name = trimprefix(var.resource_name, "svc-")
}
trimspace(string)
Removes leading and trailing Unicode whitespace:
> trimspace(" hello world \n")
"hello world"
locals {
clean_input = trimspace(var.user_provided_value)
}
String Matching Functions
These return booleans and are useful in conditionals:
> startswith("http://example.com", "http://")
true
> endswith("main.tfvars", ".tfvars")
true
> strcontains("production-database-primary", "database")
true
locals {
is_https = startswith(var.api_url, "https://")
is_prod = strcontains(var.environment, "prod")
}
Regular Expression Functions
Terraform uses RE2 syntax (no PCRE features like backreferences):
regex(pattern, string)
Returns the first match or capture group; errors if no match:
> regex("v([0-9]+\\.[0-9]+)", "product-version v1.23-beta")
["1.23"]
locals {
version = regex("v([0-9.]+)", var.release_tag)[0]
}
regexall(pattern, string)
Returns all non-overlapping matches:
> regexall("\\b[a-z]{3}\\b", "cat sat on the mat")
["cat", "sat", "mat"]
locals {
all_ips = regexall("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+", var.log_content)
}
Unicode Considerations
Terraform handles strings as Unicode characters, not bytes. This matters for length and substring operations:
> length("Hello")
5
> length("你好") # Chinese characters
2 # Not 6 bytes
# When length matters, always consider your character set
Template Directives: Conditionals and Loops
Template directives extend heredocs with control flow, enabling conditional logic and iteration directly within strings.
Conditional Logic: %{if condition}
The if directive includes parts of a string based on a boolean condition:
%{if <CONDITION>}
content if true
%{else}
content if false (optional)
%{endif}
The %{else} block is optional. If the condition is false and no else exists, nothing is rendered for that directive.
Example: Environment-Specific Deployment Message
variable "environment" {
type = string
default = "staging"
}
resource "local_file" "deployment_notes" {
filename = "${path.module}/deployment_notes.txt"
content = <<-EOT
Deployment Details:
Target Environment: ${var.environment}
%{if var.environment == "production"}
IMPORTANT: This is a PRODUCTION deployment.
- Verify all pre-flight checks are complete
- Monitor dashboards post-deployment
- Have a rollback plan ready
%{else if var.environment == "staging"}
This is a STAGING deployment.
Ideal for final testing before production.
%{else}
This is a DEVELOPMENT or QA deployment.
Feel free to experiment. Data may be wiped.
%{endif}
Deployment initiated at: ${timestamp()}
EOT
}
Iteration and Loops: %{for item in collection}
The for directive generates repeated template content for each element in a collection:
For Lists:
%{for ITEM in COLLECTION}
content using ${ITEM}
%{endfor}
%{for INDEX, ITEM in COLLECTION}
content using ${INDEX} and ${ITEM}
%{endfor}
For Maps:
%{for KEY, VALUE in MAP}
content using ${KEY} and ${VALUE}
%{endfor}
Example: Generating Access Rules
variable "readonly_users" {
type = list(string)
default = ["auditor", "guest_viewer", "support_tier1"]
}
output "user_access_policy" {
value = <<-POLICY
# Read-Only User Access
%{for user_name in var.readonly_users}
define_access {
user = "${user_name}"
role = "read_only"
status = "active"
}
%{endfor}
POLICY
}
Example: Generating Firewall Rules from a Map
variable "service_ports" {
type = map(number)
default = {
"http" = 80
"https" = 443
"ssh" = 22
}
}
output "firewall_config" {
value = <<-RULES
# Service Port Configuration
%{for service, port in var.service_ports}
firewall_rule {
name = "allow_${service}"
port = ${port}
protocol = "tcp"
action = "accept"
description = "Allow incoming ${upper(service)} traffic"
}
%{endfor}
RULES
}
Managing Whitespace with the Tilde Modifier
A common challenge with directives in heredocs is unwanted whitespace. The tilde (~) modifier controls this:
%{for item in list ~}removes whitespace following the directive on that line~%{endfor}removes whitespace preceding the directive on that line
Before: Unstripped Output
variable "features_enabled" {
type = list(string)
default = ["feature_x", "feature_y", "feature_z"]
}
output "feature_list_unstripped" {
value = <<-LIST
Enabled Features:
%{ for feature in var.features_enabled }
- ${feature}
%{ endfor }
LIST
}
# Output has extra newlines:
# Enabled Features:
#
# - feature_x
#
# - feature_y
#
# - feature_z
After: With Tilde Modifier
output "feature_list_stripped" {
value = <<-LIST
Enabled Features:
%{ for feature in var.features_enabled ~}
- ${feature}
%{ endfor ~}
LIST
}
# Clean output:
# Enabled Features:
# - feature_x
# - feature_y
# - feature_z
The tilde is powerful but requires careful testing—always verify your output!
The templatefile Function
The templatefile function separates template content from Terraform code, improving maintainability for complex string generation.
The Problem: Mixing Logic and Configuration
Large heredocs with extensive interpolation and directives can clutter your main configuration:
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
user_data = <<-EOT
#!/bin/bash
echo "Hello, ${var.environment}!"
%{for port in var.open_ports}
firewall-cmd --permanent --add-port=${port}/tcp
%{endfor}
firewall-cmd --reload
EOT
}
The Solution: Separate Template Files
Create a .tftpl template file (e.g., templates/user_data.tftpl):
#!/bin/bash
echo "Hello, ${environment}!"
%{for port in open_ports}
firewall-cmd --permanent --add-port=${port}/tcp
%{endfor}
firewall-cmd --reload
Then reference it in your configuration:
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
user_data = templatefile("${path.module}/templates/user_data.tftpl", {
environment = var.environment
open_ports = var.open_ports
})
}
Benefits
- Cleaner Configuration: Your main
.tffiles remain focused on resource definitions - Template Reusability: Share the same template across multiple resources
- Easier Maintenance: Edit templates without touching Terraform code
- Better IDE Support: Some editors provide syntax highlighting for
.tftplfiles
Template Variable Mapping
The second argument to templatefile() is a map of template variables. Template variables use simple names (no var. prefix):
# Template file: templates/config.tftpl
database_host = "${db_host}"
database_port = ${db_port}
database_name = "${db_name}"
# Terraform code:
local_file.config = {
content = templatefile("${path.module}/templates/config.tftpl", {
db_host = aws_db_instance.main.endpoint
db_port = aws_db_instance.main.port
db_name = aws_db_instance.main.name
})
}
Advanced Templating Patterns
Using jsonencode and yamlencode for Configuration Data
CRITICAL: Always use jsonencode() and yamlencode() for generating JSON or YAML instead of manually constructing them with heredocs and interpolation. Manual construction is error-prone due to strict syntax requirements (quotes, commas, braces, YAML indentation).
Incorrect Approach (Error-Prone):
resource "aws_iam_role_policy" "example" {
name = "example-policy"
role = aws_iam_role.example.id
# Manual JSON - risky!
policy = <<-EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::${var.bucket_name}/*"]
}
]
}
EOF
}
Correct Approach:
resource "aws_iam_role_policy" "example" {
name = "example-policy"
role = aws_iam_role.example.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = ["arn:aws:s3:::${var.bucket_name}/*"]
}
]
})
}
The jsonencode() function handles all escaping and formatting automatically, ensuring valid output.
Combining Multiple Features
A real-world example combining heredocs, interpolation, directives, and functions:
variable "services" {
type = map(object({
port = number
enabled = bool
tags = list(string)
}))
default = {
web = {
port = 80
enabled = true
tags = ["frontend", "public"]
}
api = {
port = 8080
enabled = true
tags = ["backend"]
}
admin = {
port = 9000
enabled = false
tags = ["admin", "internal"]
}
}
}
resource "local_file" "docker_compose" {
filename = "${path.module}/docker-compose.yml"
content = yamlencode({
version = "3.8"
services = {
for name, config in var.services :
name => {
image = "myapp:${name}"
port = ["${config.port}:${config.port}"]
enabled = config.enabled
labels = {
for tag in config.tags :
"service.tag" => tag
}
} if config.enabled
}
})
}
Using Locals for Intermediate Steps
Breaking complex string construction into readable pieces:
locals {
project_code_upper = upper(var.project_code)
env_short = lower(substr(var.environment, 0, 3))
random_suffix = random_id.server_suffix.hex
# Now the final string is clear
server_name = "${local.project_code_upper}-${local.env_short}-web-${local.random_suffix}"
}
resource "random_id" "server_suffix" {
byte_length = 4
}
resource "aws_instance" "web" {
tags = {
Name = local.server_name
}
}
Common Pitfalls and Solutions
A. Whitespace and Indentation Issues
Problem: Unwanted leading/trailing whitespace from heredocs or directives breaking generated scripts or YAML.
Solutions: - Always use indented heredocs (<<-EOF) for multiline strings - Master the tilde (~) modifier for directives - Ensure closing delimiters are on their own line
B. Misunderstanding Function Arguments and Returns
Problem: Passing wrong argument types (e.g., string to join() instead of a list) or misinterpreting what a function returns.
Solutions: - Consult the official Terraform documentation for each function - Use terraform console to experiment with functions and inputs - Test locally before deploying
C. Complex Regular Expressions
Problem: Regex patterns that are hard to read, debug, and maintain. Remember: Terraform uses RE2 syntax (no backreferences).
Solutions: - If a simpler string function works, use it - Keep regex patterns concise and well-commented - Test RE2 patterns specifically—don't assume PCRE compatibility
D. Escaping Special Characters
Problem: - Forgetting \" for double quotes in quoted strings - Forgetting $${ for literal ${ or %%{ for literal %{ in heredocs - Regex backslashes: a literal \ in regex needs \\ in HCL strings - Trying to use \ for escapes in heredocs (it's usually literal)
Solutions: - Understand context-specific escaping rules - Test outputs carefully - Use terraform console to verify string escaping
E. Syntax Errors in Multiline Strings
Problem: Typos in directive keywords (%{endfoor}), missing closing directives, or other HCL errors in large string blocks.
Solutions: - Proofread carefully - Use an IDE with good HCL syntax highlighting and linting - Run terraform validate regularly
F. Forgetting Input Validation
Problem: Unvalidated input variables interpolated into strings can create malformed outputs or security issues.
Solutions: - Use validation blocks for input variables - Enforce constraints (allowed values, regex patterns, ranges) - Document expected formats
variable "instance_name" {
type = string
default = "app"
validation {
condition = can(regex("^[a-z0-9-]{1,32}$", var.instance_name))
error_message = "Instance name must be 1-32 lowercase alphanumeric characters and hyphens."
}
}
Best Practices for 2026
1. Prioritize Readability and Maintainability
- Use terraform fmt consistently: Run it regularly to standardize formatting
- Choose clarity over cleverness: Avoid tangled one-liners
- Add strategic comments: Especially for complex regex, directives, or non-obvious logic
# Good: Clear intent
locals {
# Regex extracts version number from semantic versioning string
version = regex("^v([0-9]+\\.[0-9]+)", var.release_tag)[0]
}
# Not ideal: Cryptic and hard to maintain
locals {
v = regex("^v([0-9]+\\.[0-9]+)", var.release_tag)[0]
}
2. Keep Expressions and Interpolations Simple
Avoid multiple ternaries in one string:
# Not recommended:
value = var.env == "prod" ? "production" : var.env == "staging" ? "staging" : "development"
# Better: Use if directives or locals
locals {
env_label = (
var.env == "prod" ? "production" :
var.env == "staging" ? "staging" :
"development"
)
}
value = local.env_label
Break complex constructions into readable pieces:
locals {
tags = {
Environment = var.environment
Project = var.project_name
Owner = var.owner_email
CostCenter = var.cost_center
}
name_parts = [
local.tags["Project"],
local.tags["Environment"],
random_id.suffix.hex
]
resource_name = join("-", local.name_parts)
}
3. Choose the Right Tool for the Job
| Tool | Use When | Example |
|---|---|---|
Interpolation ${...} |
Simple value insertion | "name-${var.environment}" |
Directives %{...} |
Conditional logic or loops in heredocs | Multi-line config generation |
| Functions | Specific transformations | lower(), join(), regex() |
| format() | Need argument reordering/specific formatting | Complex formatted output |
| jsonencode/yamlencode | Generating JSON/YAML | Policy documents, configuration files |
| templatefile() | Large, reusable templates | External script templates |
4. Always Use jsonencode/yamlencode for Configuration Data
# YES: Guaranteed valid output
resource "aws_iam_policy" "s3_access" {
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "s3:*"
Resource = "*"
}]
})
}
# NO: Error-prone manual construction
# resource "aws_iam_policy" "s3_access" {
# policy = <<-EOF
# {
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Action": "s3:*",
# "Resource": "*"
# }]
# }
# EOF
# }
5. Use Consistent Naming and Explicit Declarations
variable "database_instance_identifier" {
type = string
default = "production-mysql"
description = "The identifier for the RDS database instance"
validation {
condition = can(regex("^[a-z0-9-]{1,63}$", var.database_instance_identifier))
error_message = "Must be lowercase alphanumeric and hyphens, 1-63 characters"
}
}
# Parameterize, don't hardcode
locals {
db_name = var.database_instance_identifier
}
6. Validate Early and Often
# During development
terraform validate
# Before committing
terraform plan -out=tfplan
# Before applying
terraform show tfplan
7. Master Whitespace Control in Heredocs
# Use indented heredocs (<<-) and the tilde modifier (~) strategically
output "clean_yaml" {
value = <<-YAML
apiVersion: v1
kind: ConfigMap
metadata:
name: ${var.config_name}
data:
config.json: |
%{for key, value in var.config_items ~}
"${key}": "${value}"
%{endfor ~}
YAML
}
8. Separate Templates from Code
Keep your main .tf files focused on infrastructure resources. Store template content in .tftpl files:
project/
├── main.tf
├── variables.tf
├── outputs.tf
├── templates/
│ ├── user_data.tftpl
│ ├── cloud_init.yaml
│ └── policy_document.json
└── locals.tf
9. Test String Outputs in terraform console
terraform console
> var.environment
"production"
> format("Instance: %s-%d", "web", 1)
"Instance: web-1"
> join("-", ["app", var.environment, "server"])
"app-production-server"
> regex("[0-9]+", "version-2024-12-01")
["2024"]
# Exit with 'exit'
10. Use Data Sources for Dynamic Values
Instead of hardcoding or manually constructing complex values, use data sources:
data "aws_availability_zones" "available" {
state = "available"
}
locals {
# Dynamically build AZ list from real AWS data
azs = data.aws_availability_zones.available.names
# Use in string interpolation
primary_az = local.azs[0]
}
Conclusion
Mastering Terraform strings is a critical step toward becoming a proficient infrastructure engineer. From simple quoted strings to complex heredocs with template directives and built-in functions, you now have a complete toolkit for dynamic, maintainable configurations.
Key Takeaways
- Prioritize Clarity: Write string logic that's easy to understand
- Choose Wisely: Select the right feature (interpolation, directives, functions) for the task
- Control Whitespace: Master
<<-EOFand the tilde (~) modifier - Automate and Validate: Use
terraform fmtandterraform validateconsistently - Test Outputs: Especially for complex generated strings
- Always Use jsonencode/yamlencode: For JSON and YAML, never manually construct
- Separate Concerns: Use
.tftplfiles for large templates - Validate Inputs: Ensure variables meet expected formats
With these practices, you can build elegant, robust, and highly reusable Terraform configurations that scale with your infrastructure.
Happy Terraforming!