Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 094 / 100

Terraform templatefile function: lessons from production

Render config files from templates.

Expona Admin7 min read

Introduction

We've all faced the moment when a CloudFormation template, Lambda function, or EC2 user data script needs to live in Terraform but contains dynamic values that depend on variables, locals, or computed outputs. The Terraform templatefile function: lessons from production is one of the most underrated tools for bridging that gap. Rather than hardcoding configuration or maintaining separate shell scripts and policy documents outside version control, templatefile() lets us render text-based configuration files with variable substitution—keeping everything in HCL and version-controlled. Let's walk through what we've learned applying this function across real infrastructure scenarios.

Understanding templatefile(path, vars)

At its core, templatefile() is straightforward: it reads a file, replaces templated variables using the syntax ${variable_name}, and returns the rendered string. The function signature is:

hcl
templatefile(path, vars)

The first argument is a relative or absolute file path to your template. The second is a map of variable names to their values. When Terraform evaluates the function, it scans the template file for any ${...} markers and substitutes them.

A minimal example shows the concept immediately:

hcl
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  user_data = templatefile("${path.module}/user_data.sh", {
    environment = var.environment
    log_group   = aws_cloudwatch_log_group.app.name
  })

  tags = {
    Name = "web-server"
  }
}

The template file user_data.sh might contain:

hcl
#!/bin/bash
set -e
echo "Environment: ${environment}"
echo "Logs to: ${log_group}"

When Terraform plans or applies, templatefile() renders the script with the actual environment name and log group, making it dynamic and traceable.

Great for user data and policies

Two use cases dominate production: EC2 user data scripts and IAM policy documents. Both are text-heavy and benefit enormously from variable injection.

User data scripts

Building AMIs is great for immutable infrastructure, but sometimes we need runtime configuration. A common pattern is to pass secrets, endpoints, and feature flags via user data. Instead of inline shell syntax in Terraform, we maintain a readable script file and template it:

hcl
resource "aws_instance" "app_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  iam_instance_profile = aws_iam_instance_profile.app.name

  user_data = base64encode(
    templatefile("${path.module}/templates/user_data.sh", {
      app_name          = var.app_name
      db_endpoint       = aws_db_instance.postgres.endpoint
      redis_endpoint    = aws_elasticache_cluster.redis.cache_nodes[0].address
      region            = var.aws_region
      cloudwatch_group  = aws_cloudwatch_log_group.app.name
    })
  )

  tags = {
    Name        = "${var.app_name}-server"
    Environment = var.environment
  }
}

The template file stays clean and versioned:

hcl
#!/bin/bash
set -e

export APP_NAME="${app_name}"
export DB_HOST="${db_endpoint}"
export REDIS_URL="redis://${redis_endpoint}:6379"
export AWS_REGION="${region}"

# Install CloudWatch agent and configure logging
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config \
  -m ec2 \
  -c file:/opt/cloudwatch-config.json \
  -s

# Start application
systemctl start myapp
systemctl enable myapp

This approach keeps infrastructure-as-code readable and allows operators to see exactly what configuration lands on instances.

IAM policy documents

Inline IAM policy JSON in Terraform can become unwieldy. Using templatefile() to render policy documents makes them maintainable and allows dynamic principal ARNs, resource names, and conditions:

hcl
resource "aws_iam_role_policy" "lambda_dynamodb" {
  name   = "lambda-dynamodb-policy"
  role   = aws_iam_role.lambda.id
  policy = templatefile("${path.module}/policies/lambda_dynamodb.json", {
    table_arn = aws_dynamodb_table.orders.arn
    account_id = data.aws_caller_identity.current.account_id
  })
}

The policy template file:

hcl
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem"
      ],
      "Resource": "${table_arn}"
    },
    {
      "Effect": "Allow",
      "Action": "logs:CreateLogGroup",
      "Resource": "arn:aws:logs:*:${account_id}:*"
    }
  ]
}

Not only is the policy readable in a dedicated file, it's also easier for security reviews and updates. Changes are tracked in version control alongside your infrastructure code.

Keep logic minimal in templates

A critical lesson from production: templates are not programming languages. The Terraform template syntax supports for loops and conditionals, but overusing them creates maintenance headaches. We've seen templates that looked more like mini Terraform configs than configuration files—difficult to debug and impossible to reason about.

The template syntax includes %{for ...}, %{if ...}, and %{endif}, but use them sparingly. A reasonable example is iterating over a list of environment variables:

hcl
# template file: app_env.sh
%{for key, value in environment_vars}
export ${key}="${value}"
%{endfor}

Invoked as:

hcl
data "local_file" "env_script" {
  filename = templatefile("${path.module}/templates/app_env.sh", {
    environment_vars = {
      NODE_ENV  = var.environment
      PORT      = 3000
      LOG_LEVEL = "info"
    }
  })
}

However, avoid complex conditionals or nested loops. If your template syntax is harder to read than the rendered output, move the logic into Terraform locals or use for_each to build the variable map first. For example, instead of:

hcl
# Bad: logic in template
%{if enable_feature_x}
  FEATURE_X_ENABLED=true
%{else}
  FEATURE_X_ENABLED=false
%{endif}

Build the variables in Terraform:

hcl
# Good: logic in HCL
locals {
  feature_flags = {
    FEATURE_X_ENABLED = var.enable_feature_x ? "true" : "false"
    FEATURE_Y_ENABLED = var.enable_feature_y ? "true" : "false"
  }
}

resource "aws_instance" "app" {
  user_data = templatefile("${path.module}/templates/user_data.sh", {
    feature_flags = local.feature_flags
  })
}

Then iterate over the map in your template or reference values directly. This keeps templates readable and logic concentrated in HCL, where your team expects it.

jsonencode for JSON documents instead

A trap we've fallen into repeatedly: using templatefile() for JSON documents when jsonencode() is a better fit. If you're generating JSON that only contains Terraform values (no external configuration), use jsonencode() directly in HCL. It handles escaping, formatting, and type coercion automatically.

For instance, if your Lambda function needs a policy document built entirely from Terraform values, skip the template:

hcl
# Less ideal: template file
policy = templatefile("${path.module}/policies/policy.json", {
  table_arn = aws_dynamodb_table.foo.arn
})

# Better: use jsonencode
policy = jsonencode({
  Version = "2012-10-17"
  Statement = [
    {
      Effect = "Allow"
      Action = [
        "dynamodb:GetItem",
        "dynamodb:PutItem"
      ]
      Resource = aws_dynamodb_table.foo.arn
    }
  ]
})

jsonencode() is type-safe, handles nested structures naturally, and is easier to refactor. Reserve templatefile() for cases where you're rendering semi-static text (shell scripts, config files) that contains embedded Terraform variables.

However, templatefile() remains the right choice when your JSON (or other text) lives in a file maintained separately from your Terraform code—for instance, a company policy template stored in a shared repository, with only certain fields replaced by Terraform.

Common mistakes

Escaping and quotes: Template variables are inserted as-is. If you're rendering JSON or YAML, ensure your Terraform values are already properly escaped. Passing a string containing quotes or special characters without escaping will break the output. Use jsonencode() as a pre-processing step if needed.

Relative paths: Always use ${path.module} to anchor template file paths. Relative paths like "./templates/file.json" can break when running Terraform from different directories or in remote execution contexts.

Forgetting to encode user data: Some AWS resources expect user data in a specific format (e.g., base64-encoded for EC2). Remember to wrap templatefile() with base64encode() or the appropriate encoding function.

Over-templating JSON: Resist the urge to template every JSON field. Use jsonencode() for dynamic JSON when possible, and reserve templates for truly static text files with variable insertion.

Missing variables in the vars map: If your template references ${some_variable} but you don't pass some_variable in the vars map, Terraform will error. Use consistent variable names and test plan output carefully.

Key takeaways

  • templatefile(path, vars) reads a file and substitutes ${variable_name} markers with values from a Terraform map, returning a rendered string.
  • Use templates for user data scripts and policy documents to keep configuration readable, versioned, and maintainable.
  • Keep template logic minimal; move complex conditionals and loops into Terraform HCL where they belong.
  • Prefer jsonencode() for generating JSON entirely within Terraform; reserve templatefile() for static files with variable insertion.
  • Always use ${path.module} to ensure template paths are relative to your module directory.
  • Verify that template variables are properly escaped for their target format (shell, JSON, YAML, etc.).
  • Test template output in plan mode and review actual rendered content before applying to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles