
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.
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.
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
DELIMITERThis 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 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
}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"
ENDSCRIPTWhen your string needs to include special characters, use escape sequences starting with a backslash ().
Sequence
Meaning
\n
Newline
\r
Carriage return
\t
Tab
\"
Literal double quote
\\
Literal backslash
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.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 makes your strings dynamic by embedding Terraform expressions directly within them.
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"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 shine when managing complex multiline content. Combined with string interpolation and template directives, they're powerful for generating configuration files and scripts.
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.
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
}Terraform provides a rich set of built-in functions for string manipulation. These work within interpolations, local values, outputs, and function calls.
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 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)
}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-]/", "")
}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}")
}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"
}Removes trailing newlines (\n, \r\n):
> chomp("some text\n")
"some text"
locals {
clean_content = chomp(file("${path.module}/config.txt"))
}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-")
}Removes leading and trailing Unicode whitespace:
> trimspace(" hello world \n")
"hello world"
locals {
clean_input = trimspace(var.user_provided_value)
}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")
}Terraform uses RE2 syntax (no PCRE features like backreferences):
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]
}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)
}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 setTemplate directives extend heredocs with control flow, enabling conditional logic and iteration directly within strings.
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
}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
}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 lineBefore: 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_zAfter: 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_zThe tilde is powerful but requires careful testing—always verify your output!
The templatefile function separates template content from Terraform code, improving maintainability for complex string generation.
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
}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 --reloadThen 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
})
}.tf files remain focused on resource definitions.tftpl filesThe 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
})
}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.
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
}
})
}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
}
}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
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
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
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
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
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."
}
}# 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]
}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_labelBreak 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)
}| 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 |
# 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
# }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
}# During development
terraform validate
# Before committing
terraform plan -out=tfplan
# Before applying
terraform show tfplan# 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
}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
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'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]
}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.
<<-EOF and the tilde (~) modifierterraform fmt and terraform validate consistently.tftpl files for large templatesWith these practices, you can build elegant, robust, and highly reusable Terraform configurations that scale with your infrastructure.
Happy Terraforming!
