Terraform Lab – Azure Hands-On – Terraform Template

Level: Beginner to Intermediate
Format: Tutorial + concept guide + practical lab workbook
Modules: 6 | Labs: 12 | Cloud emphasis: Azure Linux VM, AWS EC2 comparison
Baseline: Terraform 1.15.9+ (within 1.x); AzureRM 5.9.x; AWS provider 6.x
Updated: 14 October 2026
Focus: Learn Terraform string templates and templatefile(), not Terraform module development or general cloud networking.

Connection to your previous Azure exercises: Your earlier Labs 1–4 / six-variable lesson used AzureRM 5.9.x, an Ubuntu 26.04 VM, a VNet/subnet/NIC and a larger 13-resource Windows+Ubuntu setup. This course reuses those familiar concepts but intentionally reduces the cloud portion to five Azure resources and one Linux VM. These are fresh standalone labs with independent Terraform state, not drop-in replacements for the earlier applied state. Do not overwrite your previous labs’ main.tf or state.

Learning outcomes

By completion, a learner can explain template scope, render strings and files, use conditional and loop directives, control whitespace, escape Terraform sequences, encode structured data safely, generate and inspect Linux shell scripts and cloud-init, attach a rendered template to an Azure VM, distinguish Azure custom_data from AWS user_data, and validate generated artifacts before deployment.

Requirements and setup

  • Terraform CLI. The guide targets the user-provided Terraform 1.15.9 baseline. Check terraform version.
  • Text editor; Bash or POSIX shell; python3 for an optional JSON syntax check.
  • Labs 01–09 need no cloud provider, credentials or paid resources.
  • Azure Labs 10 and 12: Azure CLI, a permitted subscription, AzureRM 5.9.x provider access, an existing local SSH public key, quota for the selected VM, and permissions to deploy five resources.
  • Optional AWS Lab 11: AWS CLI/credentials, an existing subnet, a matching Linux AMI and permission to create EC2 instances.
  • Both cloud deployments incur cost until terraform destroy. Review the plan before any apply.

One-time setup (macOS/Linux):

terraform version
# For Azure labs only:
az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
# Check your public key before running Azure labs:
ls -l "$HOME/.ssh/id_ed25519.pub" || true
# Generate a new key only when neither file already exists:
if [ ! -e "$HOME/.ssh/id_ed25519" ] && [ ! -e "$HOME/.ssh/id_ed25519.pub" ]; then
  ssh-keygen -t ed25519 -f "$HOME/.ssh/id_ed25519"
fi
# Confirm there is a usable public key. If not, use a different existing key.
test -f "$HOME/.ssh/id_ed25519.pub"

Skip ssh-keygen when the public key already exists. If your organization uses a different key, pass -var='ssh_public_key_path=/absolute/path/to/key.pub'. We never put passwords, private keys, tokens or secrets inside example templates.

Training architecture

flowchart TB
    A[Input variables / HCL data] --> B[templatefile path + vars object]
    T[templates/*.tftpl] --> B
    B --> C[Rendered UTF-8 text]
    C --> D["Outputs and local previews<br/>Labs 01-09"]
    C --> E["Azure VM custom_data<br/>base64encode Lab 10/12"]
    C --> F["AWS EC2 user_data<br/>plain text Lab 11"]
    E --> G[cloud-init at first boot]
    F --> H[User-data script at first boot]

Progressive lab map

LabModuleAdds one main conceptRun environment
011Inline string interpolationLocal, free
022.tftpl and templatefile()Local, free
032Expressions and nested object scopeLocal, free
043%{ if }, %{ else }, %{ endif }Local, free
053%{ for } over a listLocal, free
063Map iteration and ~ whitespaceLocal, free
074jsonencode() and template debuggingLocal, free
085Linux shell startup script + escapingLocal, free
095Cloud-init YAML previewLocal, free
105Azure VM custom_dataAzure (billable)
115AWS EC2 user_dataAWS (optional; billable)
126Composition, maintainability, end-to-end verificationAzure (billable)

How to use the ZIP: Every lab is a self-contained snapshot, complete with its own main.tf, template files and README.md. Start Lab 01, then move to the next lab and compare code. You can run Labs 01–09 independently; cloud labs have their own isolated states and distinct Azure resource prefixes. Do not apply both Azure labs unless you intend to pay for two VMs. For a real-world stateful upgrade, use one working folder and first inspect terraform plan—changing VM custom data usually requires VM replacement.


Module 1 — Introduction to Terraform Templates

1.1 What is a Terraform template?

A Terraform template is text containing Terraform interpolation or template directives that Terraform evaluates to produce text. You can place a small template in a quoted/heredoc string, or put a larger one in a UTF-8 .tftpl file and render it with templatefile().

A template is not a Terraform module. A module groups Terraform configuration that defines infrastructure. A template is a way to generate text such as application config, shell scripts, HTML, cloud-init or EC2 user data. Generating a string alone creates no cloud resources.

ItemRoleExample
VariableHolds typed inputvar.environment = "dev"
InterpolationInserts a value"server-${var.environment}"
.tftpl fileHolds reusable formatted texttemplates/app.conf.tftpl
templatefile()Renders an external template with supplied valuestemplatefile("...", { environment = var.environment })
Terraform moduleGroups infrastructure configurationmodule "network" { ... } — not taught here

When not to use a template: A single string is often clearer as "prefix-${var.name}". For JSON/YAML, prefer jsonencode/yamlencode over hand-building escaped strings. Use an external .tftpl file when it makes a longer output easier to read and review.

1.2 Your first result

Below we start without providers, backend setup, resources or credentials. You can run the first nine labs anywhere the Terraform CLI is installed.

Lab 01 — First template: inline interpolation

Estimated classroom slot: 10 min
Execution: Local only — no cloud costs

Objective: Learn that Terraform string expressions can render a changing message without any cloud provider.

What changed from the previous lab: Starting point. Two input variables and one output; no resources or templates directory.

Step 1 — Understand the folder

labs/01-inline-interpolation/
  main.tf
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

output "message" {
  value = "App ${upper(var.app_name)} runs in ${var.environment}"
}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw message

Expected: App DEMOAPP runs in dev

Step 4 — Student practice

Run terraform apply -auto-approve -var="environment=stage". Confirm the message changes without provisioning anything.


Module 2 — Template Fundamentals

Core syntax to remember

An interpolation ${expression} inserts the value of an expression, like ${app.name} or ${upper(app.name)}. A template file is normally named *.tftpl and does not create resources by itself.

output "result" {
  value = templatefile("${path.module}/templates/message.tftpl", {
    app_name = var.app_name
  })
}

Scope rule: var.app_name is available in the parent .tf; inside the external file it is available only as app_name because that is the key passed in the second argument. path.module makes the template path relative to the running configuration directory, not your terminal’s current working directory.

Lab 02 — Extract a reusable .tftpl file

Estimated classroom slot: 10 min
Execution: Local only — no cloud costs

Objective: Move the text outside the Terraform configuration and pass exactly two named values to templatefile().

What changed from the previous lab: Retains the two variables from Lab 01. Replaces the inline output string with an external template.

Step 1 — Understand the folder

labs/02-first-templatefile/
  main.tf
  templates/message.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

output "message" {
  value = templatefile("${path.module}/templates/message.tftpl", {
    app_name    = var.app_name
    environment = var.environment
  })
}

templates/message.tftpl

App ${upper(app_name)} runs in ${environment}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw message

Expected: App DEMOAPP runs in dev

Step 4 — Student practice

Change templates/message.tftpl to add Welcome to ${environment} and confirm that terraform output -raw message changes after terraform apply.

Lab 03 — Expressions, objects, and template scope

Estimated classroom slot: 10 min
Execution: Local only — no cloud costs

Objective: Pass an object into a template and access its fields. Practice a function and a number inside interpolation.

What changed from the previous lab: The two scalar variables become one app object. Inside the template the object is named app, not var.app.

Step 1 — Understand the folder

labs/03-objects-and-scope/
  main.tf
  templates/message.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app" {
  type = object({
    name        = string
    environment = string
    port        = number
  })
  default = {
    name        = "demoapp"
    environment = "dev"
    port        = 8080
  }
}

output "message" {
  value = templatefile("${path.module}/templates/message.tftpl", {
    app = var.app
  })
}

templates/message.tftpl

App ${upper(app.name)} (${app.environment}) listens on ${app.port}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw message

Expected: App DEMOAPP (dev) listens on 8080

Step 4 — Student practice

Change app.port to 9090 in the default object and inspect the newly rendered output.


Module 3 — Template Directives

Directives choose or repeat whole text sections. They are not Terraform resource loops and they do not deploy any infrastructure.

%{ if enable_logging ~}
logging=enabled
%{ else ~}
logging=disabled
%{ endif ~}
%{ for item in items ~}
item=${item}
%{ endfor ~}
  • %{ if condition } begins a conditional; %{ else } is optional; %{ endif } closes it.
  • %{ for item in items } iterates over a collection; %{ endfor } closes it.
  • %{ for key, value in settings } iterates through map entries.
  • The ~ immediately before } consumes whitespace after the sequence. A ~ immediately after %{ consumes whitespace before the sequence. Both can help remove directive-only blank lines.
  • Do not confuse template directives with for_each, count, or dynamic resource blocks.

Lab 04 — Conditional %{ if } and %{ else }

Estimated classroom slot: 15 min
Execution: Local only — no cloud costs

Objective: Render exactly one of two configuration lines based on a boolean input.

What changed from the previous lab: Keeps the Lab 03 app object and adds enable_logging with %{ if }, %{ else }, and %{ endif }.

Step 1 — Understand the folder

labs/04-if-else-directives/
  main.tf
  templates/app.conf.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app" {
  type = object({ name = string, environment = string })
  default = { name = "demoapp", environment = "dev" }
}

variable "enable_logging" {
  type    = bool
  default = true
}

output "config" {
  value = templatefile("${path.module}/templates/app.conf.tftpl", {
    app            = var.app
    enable_logging = var.enable_logging
  })
}

templates/app.conf.tftpl

name=${app.name}
environment=${app.environment}
%{ if enable_logging ~}
logging=enabled
%{ else ~}
logging=disabled
%{ endif ~}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw config

Expected: name=demoapp; env=dev; logging=enabled (on separate lines)

Step 4 — Student practice

Set -var="enable_logging=false", apply, and confirm the template produces logging=disabled instead.

Lab 05 — Generate repeated lines with %{ for }

Estimated classroom slot: 15 min
Execution: Local only — no cloud costs

Objective: Use a list to render a package entry for every item instead of repeating code.

What changed from the previous lab: The conditional configuration from Lab 04 stays. Add packages and one %{ for package in packages } directive.

Step 1 — Understand the folder

labs/05-for-list-directive/
  main.tf
  templates/app.conf.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app" {
  type = object({ name = string, environment = string })
  default = { name = "demoapp", environment = "dev" }
}

variable "enable_logging" {
  type    = bool
  default = true
}

variable "packages" {
  type    = list(string)
  default = ["nginx", "curl"]
}

output "config" {
  value = templatefile("${path.module}/templates/app.conf.tftpl", {
    app            = var.app
    enable_logging = var.enable_logging
    packages       = var.packages
  })
}

templates/app.conf.tftpl

name=${app.name}
environment=${app.environment}
%{ if enable_logging ~}
logging=enabled
%{ else ~}
logging=disabled
%{ endif ~}
%{ for package in packages ~}
package=${package}
%{ endfor ~}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw config

Expected: Includes package=nginx and package=curl, one item per line.

Step 4 — Student practice

Add "git" to packages, rerun the lab, and verify exactly three package= entries.

Lab 06 — Loop through a map and control whitespace

Estimated classroom slot: 15 min
Execution: Local only — no cloud costs

Objective: Add key/value pairs and remove extra blank lines using ~ without changing the configuration design.

What changed from the previous lab: The previous list loop stays. Add settings = map(string) and %{ for key, value in settings ~}.

Step 1 — Understand the folder

labs/06-maps-and-whitespace/
  main.tf
  templates/app.conf.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app" {
  type = object({ name = string, environment = string })
  default = { name = "demoapp", environment = "dev" }
}

variable "enable_logging" {
  type    = bool
  default = true
}

variable "packages" {
  type    = list(string)
  default = ["nginx", "curl"]
}

variable "settings" {
  type = map(string)
  default = { log_level = "info", workers = "2" }
}

output "config" {
  value = templatefile("${path.module}/templates/app.conf.tftpl", {
    app            = var.app
    enable_logging = var.enable_logging
    packages       = var.packages
    settings       = var.settings
  })
}

templates/app.conf.tftpl

name=${app.name}
environment=${app.environment}
%{ if enable_logging ~}
logging=enabled
%{ else ~}
logging=disabled
%{ endif ~}
%{ for package in packages ~}
package=${package}
%{ endfor ~}
%{ for key, value in settings ~}
${key}=${value}
%{ endfor ~}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw config

Expected: Includes log_level=info, workers=2, and no blank line created by directive-only lines.

Step 4 — Student practice

Remove ~ from the for line and apply. Compare the output; then restore it.

Instructor note: Map keys are traversed in lexical order for Terraform map/object values. Do not use a set when output order is a requirement.


Module 4 — templatefile() in Depth

API, files, and constraints

templatefile(path, vars)
  • path is an existing local UTF-8 text file, typically referenced using ${path.module}/templates/....
  • vars is an object/map. Only its top-level keys are in scope in the .tftpl file; their values can be nested objects, lists and maps.
  • Template keys must start with a letter, then contain only letters, digits or underscores.
  • templatefile() cannot call itself recursively and it cannot read a file created later during the same Terraform run. It is not a cloud service or a Terraform resource.
  • file(path) reads literal text unchanged, while templatefile() renders template expressions. If you need a literal ${...} in a rendered template, write $${...} in the source; for literal %{...}, write %%{...}.
  • Avoid the legacy data "template_file" from the archived hashicorp/template provider. Modern Terraform has a built-in templatefile() function, requiring no extra template provider.

Troubleshooting table

Error / symptomWhyFix
Invalid value for "path"File missing or wrong relative directoryUse ${path.module} and verify location
vars map does not contain key ...Template references a key you did not passAdd it to the vars object or fix its spelling
vars map does not contain key "var"Wrote ${var.environment} inside .tftplPass environment = var.environment and refer to ${environment}
Invalid function argument or malformed contentInvalid UTF-8, function use or interpolated valueValidate source encoding and expression syntax
Output literally contains unexpected $${...}Escaped one extra levelDistinguish Terraform interpolation from shell interpolation
JSON/YAML syntax invalidManually interpolated strings without escapingUse jsonencode / yamlencode

Preflight habit: terraform fmt, terraform validate, and terraform console; then plan/apply only when necessary. For JSON results, pipe terraform output -raw into python3 -m json.tool.

Lab 07 — Render JSON safely and debug templatefile()

Estimated classroom slot: 15 min
Execution: Local only — no cloud costs

Objective: Pass objects and lists through the vars map while using jsonencode() for correct quoting and escaping.

What changed from the previous lab: Switches output format from ad hoc text to JSON. This is a new format, not a new provider or resource.

Step 1 — Understand the folder

labs/07-json-and-debugging/
  main.tf
  templates/app.json.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app" {
  type = object({ name = string, environment = string, port = number })
  default = { name = "demoapp", environment = "dev", port = 8080 }
}

variable "features" {
  type    = list(string)
  default = ["metrics", "healthcheck"]
}

variable "settings" {
  type    = map(string)
  default = { workers = "2", log_level = "info" }
}

output "config_json" {
  value = templatefile("${path.module}/templates/app.json.tftpl", {
    app      = var.app
    features = var.features
    settings = var.settings
  })
}

templates/app.json.tftpl

${jsonencode({
  name        = app.name
  environment = app.environment
  port        = app.port
  features    = features
  settings    = settings
})}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw config_json | python3 -m json.tool

Expected: Valid formatted JSON with name, environment, port, features array, and settings object.

Step 4 — Student practice

Change the app name to demo "quoted"; validate the result using python3 -m json.tool rather than inserting quotes manually.

Instructor note: jsonencode() handles quote and newline escaping. Inside the template only the keys passed to templatefile exist; root-level var.app is not in scope.


Module 5 — Templates for Cloud Automation

Three formats, one concept

ContentTypical sourceHow attachedExecution moment
Linux shell scriptstartup.sh.tftplAWS user_data = templatefile(...)Usually first boot
Cloud-init YAMLcloud-init.tftplAzure custom_data = base64encode(templatefile(...))Usually first boot
App/server configapp.conf.tftplEmbedded inside cloud-init write_filesAt VM initialization

Key difference: AzureRM azurerm_linux_virtual_machine.custom_data expects Base64-encoded text. AWS Terraform aws_instance.user_data accepts ordinary script text; its provider handles API encoding. Do not double-encode the AWS field.

Security default: Azure lab VMs have only a private NIC and SSH key login; we use Azure Run Command for inspection instead of adding a public IP, NSG, bastion or provisioning connection. The demo cloud-init writes files and does not require package downloads, avoiding additional outbound-networking topics. On AWS the instance also gets no public IP from this configuration, and you must already have a working subnet and management access to inspect guest files.

Lab 08 — Render a Linux shell startup script

Estimated classroom slot: 15 min
Execution: Local only — no cloud costs

Objective: Move from configuration text to a practical Linux startup script; learn to escape shell ${NAME} safely.

What changed from the previous lab: Reuses the same app_name and environment idea. File extension stays .tftpl, but the rendered result is a shell script.

Step 1 — Understand the folder

labs/08-shell-script-template/
  main.tf
  templates/startup.sh.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

output "startup_script" {
  value = templatefile("${path.module}/templates/startup.sh.tftpl", {
    app_name    = var.app_name
    environment = var.environment
  })
}

templates/startup.sh.tftpl

#!/bin/sh
set -eu
APP_NAME="${app_name}"
ENVIRONMENT="${environment}"
printf 'Starting %s in %s\n' "$${APP_NAME}" "$${ENVIRONMENT}"
printf '%s\n' "$${APP_NAME}:$${ENVIRONMENT}" > /tmp/tf-template-bootstrap.txt

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw startup_script > /tmp/tf-template-startup.sh
sh -n /tmp/tf-template-startup.sh
sh /tmp/tf-template-startup.sh
cat /tmp/tf-template-bootstrap.txt

Expected: The script prints Starting demoapp in dev; the file contains demoapp:dev.

Step 4 — Student practice

Replace the environment with qa and rerender. Verify that the printed script contains literal shell ${APP_NAME} after template expansion.

Instructor note: The template uses Terraform $${ escaping for the literal shell ${...}. This shell script is intentionally harmless and requires no root privileges.

Lab 09 — Build cloud-init without creating a VM

Estimated classroom slot: 20 min
Execution: Local only — no cloud costs

Objective: Reuse templatefile() to produce a complete #cloud-config document and preview the YAML before provisioning.

What changed from the previous lab: Keeps the app/environment inputs from Lab 08. The output is now cloud-init YAML encoded from an HCL object.

Step 1 — Understand the folder

labs/09-cloud-init-preview/
  main.tf
  templates/cloud-init.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

output "cloud_init" {
  value = templatefile("${path.module}/templates/cloud-init.tftpl", {
    app_name    = var.app_name
    environment = var.environment
  })
}

templates/cloud-init.tftpl

#cloud-config
${yamlencode({
  write_files = [{
    path        = "/etc/tf-template/app.conf"
    permissions = "0644"
    content     = format("name=%s\nenvironment=%s\n", app_name, environment)
  }]
  runcmd = [
    ["sh", "-c", "echo READY > /var/tmp/tf-template-ready"]
  ]
})}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

terraform init -backend=false
terraform validate
terraform apply -auto-approve
terraform output -raw cloud_init > /tmp/tf-cloud-init-preview.yaml
head -15 /tmp/tf-cloud-init-preview.yaml

Expected: Starts with #cloud-config, declares /etc/tf-template/app.conf, and writes /var/tmp/tf-template-ready on first boot.

Step 4 — Student practice

Add a second nonsecret write_files entry to the array, preview the YAML, and check the indentation.

Instructor note: This lab does not require Azure/AWS credentials. It teaches cloud-init format without VM setup cost. The cloud-init document is a preview, not something executed locally.

Lab 10 — Azure Ubuntu VM from the cloud-init template

Estimated classroom slot: 40 min
Execution: Azure deploy — chargeable

Objective: Attach the exact Lab 09 template to an actual Azure Linux VM using custom_data = base64encode(templatefile(...)).

What changed from the previous lab: Same template as Lab 09; only now Azure resources are added: resource group, VNet, subnet, NIC and one Ubuntu VM (5 resources).

Step 1 — Understand the folder

labs/10-azure-ubuntu-cloud-init/
  main.tf
  templates/cloud-init.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.9.0"
    }
  }
}

provider "azurerm" {
  features {}
}

variable "location" {
  type    = string
  default = "eastus"
}

variable "name_prefix" {
  type    = string
  default = "tpl-basic"
}

variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/id_ed25519.pub"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "azurerm_resource_group" "lab" {
  name     = "${var.name_prefix}-rg"
  location = var.location
}

resource "azurerm_virtual_network" "lab" {
  name                = "${var.name_prefix}-vnet"
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name
  address_space       = ["10.80.0.0/16"]
}

resource "azurerm_subnet" "lab" {
  name                 = "${var.name_prefix}-subnet"
  resource_group_name  = azurerm_resource_group.lab.name
  virtual_network_name = azurerm_virtual_network.lab.name
  address_prefixes     = ["10.80.1.0/24"]
}

resource "azurerm_network_interface" "lab" {
  name                = "${var.name_prefix}-nic"
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.lab.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "lab" {
  name                  = "${var.name_prefix}-vm"
  location              = azurerm_resource_group.lab.location
  resource_group_name   = azurerm_resource_group.lab.name
  size                  = "Standard_B2s_v2"
  admin_username        = "labadmin"
  network_interface_ids = [azurerm_network_interface.lab.id]

  disable_password_authentication = true
  admin_ssh_key {
    username   = "labadmin"
    public_key = file(pathexpand(var.ssh_public_key_path))
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-26_04-lts"
    sku       = "server"
    version   = "latest"
  }

  custom_data = base64encode(templatefile("${path.module}/templates/cloud-init.tftpl", {
    app_name    = var.app_name
    environment = var.environment
  }))
}

output "resource_group_name" {
  value = azurerm_resource_group.lab.name
}

output "vm_name" {
  value = azurerm_linux_virtual_machine.lab.name
}

templates/cloud-init.tftpl

#cloud-config
${yamlencode({
  write_files = [{
    path        = "/etc/tf-template/app.conf"
    permissions = "0644"
    content     = format("name=%s\nenvironment=%s\n", app_name, environment)
  }]
  runcmd = [
    ["sh", "-c", "echo READY > /var/tmp/tf-template-ready"]
  ]
})}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
test -f "$HOME/.ssh/id_ed25519.pub"  # Use the preflight key-setup instructions if missing
terraform init
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
az vm run-command invoke -g "$(terraform output -raw resource_group_name)" -n "$(terraform output -raw vm_name)" --command-id RunShellScript --scripts "cloud-init status --wait; cat /etc/tf-template/app.conf; cat /var/tmp/tf-template-ready"

Expected: One private Ubuntu VM. Run Command should show name=demoapp, environment=dev and READY after cloud-init completes.

Step 4 — Student practice

Change app_name to training-app and run terraform plan only. Identify the VM replacement that custom_data would trigger; do not apply an unintended replacement.

Instructor note: This VM intentionally has no public IP or inbound SSH rule. Azure Run Command is the verification method. The cloud-init example does not install packages and therefore does not depend on outbound internet. custom_data changes normally replace the VM.

Cleanup (once your verification is complete):

terraform plan -destroy
terraform destroy

Lab 11 — AWS EC2 user-data with the same shell template

Estimated classroom slot: 25 min
Execution: AWS deploy — optional and chargeable

Objective: Compare the provider interfaces: aws_instance.user_data accepts the rendered shell script without wrapping it in base64encode().

What changed from the previous lab: Uses the shell template from Lab 08. AWS VPC/subnet and AMI are external inputs to avoid unrelated networking code.

Step 1 — Understand the folder

labs/11-aws-ec2-user-data/
  main.tf
  templates/startup.sh.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type    = string
  default = "ap-northeast-1"
}

variable "subnet_id" {
  description = "Existing subnet ID in the selected AWS region."
  type        = string
}

variable "ami_id" {
  description = "Linux AMI ID compatible with the chosen instance type."
  type        = string
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "aws_instance" "lab" {
  ami                         = var.ami_id
  instance_type               = "t3.micro"
  subnet_id                   = var.subnet_id
  associate_public_ip_address = false

  user_data = templatefile("${path.module}/templates/startup.sh.tftpl", {
    app_name    = var.app_name
    environment = var.environment
  })

  tags = { Name = "tf-template-demo" }
}

output "instance_id" {
  value = aws_instance.lab.id
}

templates/startup.sh.tftpl

#!/bin/sh
set -eu
APP_NAME="${app_name}"
ENVIRONMENT="${environment}"
printf '%s\n' "$${APP_NAME}:$${ENVIRONMENT}" > /var/tmp/tf-template-bootstrap.txt

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

aws sts get-caller-identity
aws ssm get-parameter --region ap-northeast-1 --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 --query Parameter.Value --output text
terraform init
terraform validate
terraform plan -var="subnet_id=subnet-REPLACE" -var="ami_id=ami-REPLACE"
# After replacing IDs: terraform apply -var="subnet_id=..." -var="ami_id=..."

Expected: An EC2 instance using a rendered startup script. No public IP or incoming rules are provisioned by this lab.

Step 4 — Student practice

Compare Azure custom_data = base64encode(templatefile(...)) with AWS user_data = templatefile(...). Explain why only Azure is encoded explicitly.

Instructor note: Optional AWS companion. Requires a compatible Linux AMI and an existing subnet where an instance can launch. Verification inside the guest requires pre-existing management access such as SSM; this lab does not configure a key pair, SSM role or inbound security group.

Cleanup (once your verification is complete):

terraform plan -destroy
terraform destroy

Module 6 — Best Practices and Real-World Design

Design principles for production-quality templates

  1. Put .tftpl files in a predictable templates/ directory, separate from main.tf.
  2. Render text in locals where multiple templates need to be composed; avoid nested or recursive templatefile() calls.
  3. Use descriptive keys such as environment, app_config, or health_script. Don’t pass everything simply because it exists.
  4. Prefer jsonencode or yamlencode for machine-readable formats. Let Terraform escape strings and build valid syntax.
  5. Keep secrets out of templates, scripts, logs, terraform output, user data and local previews. Terraform state and saved plans can contain rendered secret values even when CLI output is hidden.
  6. Validate the generated artifact, not just the HCL syntax: JSON parser for JSON, YAML parser for cloud-init, sh -n for shell scripts and actual guest/agent checks after provisioning.
  7. Make first-boot scripts safe to retry where possible. User data/cloud-init normally runs only at first boot, and changes can have provider-specific replacement behavior.
  8. Add a focused test case for both sides of conditionals, empty and nonempty lists/maps, and strings containing quotes or special characters.
  9. Minimize unnecessary complexity: a one-line string does not need a .tftpl file; avoid creating modules for a 5-line one-off template.
  10. Keep templates small and content-based. Do not turn them into alternative infrastructure management or put access credentials inside them.

Final scenario

A team needs to configure a newly provisioned Ubuntu VM with a small application configuration file and a health-check script. Three templates accomplish the content generation, while Terraform still creates just five Azure resources. The main teaching point is template composition.

Lab 12 — Capstone: compose three templates into one Azure VM

Estimated classroom slot: 45 min
Execution: Azure deploy — chargeable

Objective: Generate application config and a health-check script, then embed both in cloud-init while keeping Azure infrastructure unchanged.

What changed from the previous lab: Extends the Lab 10 VM pattern with three small .tftpl files and one locals block. It still creates only the same five kinds of Azure resources.

Step 1 — Understand the folder

labs/12-azure-capstone-composition/
  main.tf
  templates/app.conf.tftpl
  templates/health.sh.tftpl
  templates/cloud-init.tftpl
  README.md

Step 2 — Create/review each file

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.9.0"
    }
  }
}

provider "azurerm" {
  features {}
}

variable "location" {
  type    = string
  default = "eastus"
}

variable "name_prefix" {
  type    = string
  default = "tpl-final"
}

variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/id_ed25519.pub"
}

variable "app_name" {
  type    = string
  default = "demoapp"
}

variable "environment" {
  type    = string
  default = "dev"
}

variable "port" {
  type    = number
  default = 8080
}

variable "enable_monitoring" {
  type    = bool
  default = true
}

variable "settings" {
  type    = map(string)
  default = { log_level = "info", workers = "2" }
}

locals {
  app_config = templatefile("${path.module}/templates/app.conf.tftpl", {
    name              = var.app_name
    environment       = var.environment
    port              = var.port
    enable_monitoring = var.enable_monitoring
    settings          = var.settings
  })

  health_script = templatefile("${path.module}/templates/health.sh.tftpl", {
    name = var.app_name
  })

  cloud_init = templatefile("${path.module}/templates/cloud-init.tftpl", {
    app_config    = local.app_config
    health_script = local.health_script
  })
}

resource "azurerm_resource_group" "lab" {
  name     = "${var.name_prefix}-rg"
  location = var.location
}

resource "azurerm_virtual_network" "lab" {
  name                = "${var.name_prefix}-vnet"
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name
  address_space       = ["10.80.0.0/16"]
}

resource "azurerm_subnet" "lab" {
  name                 = "${var.name_prefix}-subnet"
  resource_group_name  = azurerm_resource_group.lab.name
  virtual_network_name = azurerm_virtual_network.lab.name
  address_prefixes     = ["10.80.1.0/24"]
}

resource "azurerm_network_interface" "lab" {
  name                = "${var.name_prefix}-nic"
  location            = azurerm_resource_group.lab.location
  resource_group_name = azurerm_resource_group.lab.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.lab.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "lab" {
  name                  = "${var.name_prefix}-vm"
  location              = azurerm_resource_group.lab.location
  resource_group_name   = azurerm_resource_group.lab.name
  size                  = "Standard_B2s_v2"
  admin_username        = "labadmin"
  network_interface_ids = [azurerm_network_interface.lab.id]

  disable_password_authentication = true
  admin_ssh_key {
    username   = "labadmin"
    public_key = file(pathexpand(var.ssh_public_key_path))
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-26_04-lts"
    sku       = "server"
    version   = "latest"
  }

  custom_data = base64encode(local.cloud_init)
}

output "resource_group_name" {
  value = azurerm_resource_group.lab.name
}

output "vm_name" {
  value = azurerm_linux_virtual_machine.lab.name
}

output "config_preview" {
  value = local.app_config
}

output "cloud_init_sha256" {
  value = sha256(local.cloud_init)
}

templates/app.conf.tftpl

name=${name}
environment=${environment}
port=${port}
%{ if enable_monitoring ~}
monitoring=enabled
%{ else ~}
monitoring=disabled
%{ endif ~}
%{ for key, value in settings ~}
${key}=${value}
%{ endfor ~}

templates/health.sh.tftpl

#!/bin/sh
set -eu
test -f /etc/tf-template/app.conf
printf '%s\n' 'healthy: ${name}' > /var/tmp/tf-template-health

templates/cloud-init.tftpl

#cloud-config
${yamlencode({
  write_files = [
    {
      path        = "/etc/tf-template/app.conf"
      permissions = "0644"
      content     = app_config
    },
    {
      path        = "/usr/local/bin/tf-template-health.sh"
      permissions = "0755"
      content     = health_script
    }
  ]
  runcmd = [["/usr/local/bin/tf-template-health.sh"]]
})}

Step 3 — Run and inspect

From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.

az login
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
terraform init
terraform validate
terraform console  # inspect local.app_config and local.cloud_init; type exit to quit
terraform plan -out=tfplan
terraform apply tfplan
az vm run-command invoke -g "$(terraform output -raw resource_group_name)" -n "$(terraform output -raw vm_name)" --command-id RunShellScript --scripts "cloud-init status --wait; cat /etc/tf-template/app.conf; cat /var/tmp/tf-template-health"

Expected: Azure VM has /etc/tf-template/app.conf with map entries and conditionals; cloud-init runs the script and writes healthy: demoapp.

Step 4 — Student practice

Change enable_monitoring to false and add owner = "platform" to settings. Use terraform console to review rendered content before any apply. In a real deployed VM this custom-data change can require replacement.

Instructor note: The three templates are composed by Terraform locals; the templates do not recursively call templatefile. Two separate previews help verify content before cloud creation. The example deliberately keeps package installation and inbound networking outside scope.

Cleanup (once your verification is complete):

terraform plan -destroy
terraform destroy

Review, validation, assessment and common pitfalls

End-to-end step-by-step runbook

  1. Extract the ZIP and read this guide plus README.md.
  2. Start with labs/01-inline-interpolation. Execute terraform init -backend=false, terraform validate, terraform apply, and terraform output.
  3. Move through Labs 02–07. Focus on the exact new line(s) added in each folder. Use terraform console to examine expressions.
  4. Run Lab 08; render the script locally; syntax-check it with sh -n; run only this harmless demo in your own shell.
  5. Run Lab 09; preview cloud-init output. Do not apply any cloud resources until you have inspected the generated YAML.
  6. Run Azure Lab 10 only if you have a budget and permission. Verify via Azure Run Command; destroy it afterward.
  7. Optionally run AWS Lab 11 using your own existing subnet and matching AMI. Do not assume it has SSH or internet.
  8. Run the final composition Lab 12 if you wish to do the complete cloud exercise. Verify the generated configuration in the guest, then destroy it.
  9. Ask students to explain where string rendering happens versus where a VM’s first-boot agent executes scripts.

Quick reference: template syntax

IntentSyntaxExample
Insert a value${...}name=${app.name}
Apply a function${...}${upper(name)}
Conditional%{ if } / %{ else } / %{ endif }See Lab 04
Repeat a list%{ for item in items } / %{ endfor }See Lab 05
Repeat a map%{ for key, value in map } / %{ endfor }See Lab 06
Strip whitespace after sequence~}%{ for item in items ~}
Strip whitespace before sequence%{~%{~ endif }
Literal shell ${HOME} in output$${HOME}See Lab 08
Literal %{ if ... } in output%%{ if ... }Only when emitting template-like text literally
Render a filetemplatefile(path, vars)See Lab 02
Safe JSON/YAMLjsonencode, yamlencodeSee Labs 07, 09

Artifact inspection examples

After running the corresponding local labs:

# Lab 07: structured JSON check
terraform output -raw config_json | python3 -m json.tool

# Lab 08: shell syntax check (after changing to Lab 08 folder)
terraform output -raw startup_script > /tmp/tf-template-startup.sh
sh -n /tmp/tf-template-startup.sh

# Lab 09: rendered cloud-init header
terraform output -raw cloud_init | head -20

For a YAML parser, use your environment’s existing YAML tooling; Python’s yaml.safe_load() is available only when the optional PyYAML package is installed. Plain terraform validate cannot prove that a rendered script will execute correctly or that a guest can access a network package repository.

Example trainer demonstration: the exact Azure vs AWS difference

# Azure (Linux VM): Base64-encode the rendered cloud-init file.
custom_data = base64encode(templatefile("${path.module}/templates/cloud-init.tftpl", {
  app_name    = var.app_name
  environment = var.environment
}))

# AWS (EC2): Supply the plain rendered script to user_data.
user_data = templatefile("${path.module}/templates/startup.sh.tftpl", {
  app_name    = var.app_name
  environment = var.environment
})

Azure migration warning if you reuse your OLD 13-resource lab

Your earlier state managed a Windows VM and an Ubuntu VM plus networking. Do not paste the new five-resource lab into that state: names, Terraform addresses, CIDRs and authentication assumptions are different. If you instead add custom_data to an already-created azurerm_linux_virtual_machine.ubuntu resource, AzureRM typically treats the custom-data change as ForceNew, which leads to VM replacement; that is not an innocuous in-place text update. Export/secure your old state and review every proposed change carefully. For teaching, use the supplied standalone Lab 10 or Lab 12 directory.

Common mistakes and repair strategies

SymptomLikely causeResolution
Terraform says file not foundWrong path or filenameCheck templates/*.tftpl; use ${path.module}
Template says key env missingPassed environment instead of envMake keys match exactly
${HOME} fails or becomes Terraform syntaxBash expansion was mistaken for TerraformWrite $${HOME} in a .tftpl file
${var.app_name} fails in a .tftpl filevar object was never passedUse ${app_name} after passing app_name = var.app_name
Weird empty lines in configDirective lines add literal newlinesUse whitespace trim markers like %{ for x in xs ~}
JSON/YAML parser rejects outputHandwritten quoting, indentation, escapingUse jsonencode() or yamlencode()
Azure VM plan shows -/+Changed custom_data / image / other ForceNew fieldStop; inspect replacement before applying
Cloud-init file missing after deployCloud-init not finished or script failedUse cloud-init status --wait, inspect guest logs
AWS user-data didn’t run on rebootFirst-boot behavior is the defaultDon’t confuse configuration with a recurring service
Terraform state contains rendered credentialsSecrets were interpolated into files/planRemove secrets; use cloud-native secret delivery and protect/revoke exposed credentials
Ubuntu VM image or size rejectedNot available in selected Azure region/subscriptionCheck az vm image list, az vm list-skus, quota/policy; choose an approved image/SKU

Security, operations and cost notes

  • The cloud labs create real chargeable VMs; terraform destroy stops Terraform-managed resources, but confirm the provider released everything.
  • Azure lab uses Canonical:ubuntu-26_04-lts:server:latest and Standard_B2s_v2, matching the earlier familiar Linux VM pattern. Availability varies by region, subscription, image/policy and capacity.
  • Azure network configuration has no public IP and no NSG inbound rule, so you cannot SSH from your laptop without designing a separate private access route. Use Azure Run Command for this training exercise.
  • AWS Lab 11 does not provision VPC resources or grant guest access: the subnet and AMI are intentionally required inputs. It similarly avoids public SSH exposure.
  • sensitive = true hides some CLI displays but does not remove secret values from state, saved plans, custom data or guest metadata. Do not use secrets in this tutorial’s templates.
  • Template files must be present before the run; do not ask templatefile() to consume files produced by a later resource in the same apply.
  • Templates create text; providers and cloud-init/user-data determine when and how that text is delivered and executed.

Two short assignments (20–30 minutes)

Assignment 1 — Safe config generation (no cloud): Start from Lab 06. Set environment = "stage", turn enable_logging = false, add package=git and owner=platform, then produce clean, nonblank config lines. Acceptance: correct condition, three packages, two or more settings, no duplicate blank lines.

Assignment 2 — Azure cloud-init customization: Start from Lab 12. Add another safe setting named team = "training"; change the health script marker filename to /var/tmp/tf-template-training. Preview the final local.cloud_init, validate, deploy only after a clean plan and confirm the new file using Run Command. Acceptance: exactly five planned Azure resources in a new environment; rendered text is correct; cleanup succeeds.

Trainer comprehension questions and answers

  1. What is the difference between variables and templates? Variables are typed inputs; templates generate formatted output text from input values.
  2. Does templatefile deploy anything? No. It renders a string; a resource argument such as custom_data or user_data makes it useful to cloud automation.
  3. Why not use ${var.app_name} inside a .tftpl file? Only names supplied by the vars object are in scope.
  4. How do we safely render JSON/YAML? Use jsonencode()/yamlencode() from an object instead of manually concatenating punctuation and quotes.
  5. What does $${VAR} do? Renders literal ${VAR} for a later interpreter such as a shell.
  6. Why does Azure encode but AWS does not? The AzureRM Linux VM custom_data schema expects Base64 content; the AWS EC2 provider’s user_data argument expects plain text.
  7. Does changed cloud-init automatically rerun on an existing VM? No; it is typically a first-boot mechanism and AzureRM custom-data changes typically force VM replacement.

Packaged content and optional automated checks

Terraform_Templates_Training_2026/
├── Terraform_Templates_Step_by_Step_Training.md
├── README.md
├── .gitignore
├── labs/
│   ├── 01-inline-interpolation/
│   ├── 02-first-templatefile/
│   ├── 03-objects-and-scope/
│   ├── 04-if-else-directives/
│   ├── 05-for-list-directive/
│   ├── 06-maps-and-whitespace/
│   ├── 07-json-and-debugging/
│   ├── 08-shell-script-template/
│   ├── 09-cloud-init-preview/
│   ├── 10-azure-ubuntu-cloud-init/
│   ├── 11-aws-ec2-user-data/
│   └── 12-azure-capstone-composition/
└── scripts/
    ├── validate_labs.sh
    └── test_local_labs.sh

After installing Terraform and giving the machine internet access for provider downloads:

bash scripts/validate_labs.sh   # fmt + init + validate; cloud providers download but no apply
bash scripts/test_local_labs.sh # apply LOCAL labs 01-09; no cloud provisioning

Do not mistake format/validation checks for a cloud deployment test. Verify account, quota, policy, image SKU and guest initialization in your own environment.

Official sources

Validation transparency: This course was assembled as local files and inspected for packaging, source/code consistency and script syntax. It was not deployed against live Azure/AWS accounts. Terraform CLI/provider validation is documented as a learner-side step and requires the CLI plus provider downloads.

Related Posts

Terraform tflint terraform-docs trivy tenv pre-commit Tools — Installation Guide for Windows PowerShell

Updated: October 2026 | Windows 10/11 | PowerShell 5.1 / 7 This guide covers installing, verifying, and running the first commands for five Terraform development tools. Tool…

Read More

HCP Terraform Cloud CLI — Master Command Summary Table

Organization: motoshare | Project: proezi | Workspaces: proezi_dev, proezi_stage, proezi_prod 1. Terraform CLI — Complete Command Reference Category Command Purpose / Usage Installation terraform version Check installed…

Read More

HCP Terraform Cloud CLI — Complete Master Tutorial

HCP Terraform Cloud — CLI Command Summary Organization: motoshare | Project: proezi # Cloud operation Command / Example Usage 1 Login to Terraform Cloud terraform login Authenticate…

Read More

Terraform Lab – Azure Hands-On – Terraform Functions

Beginner to Intermediate · 8 modules · 22 progressively designed labs · 20 local-only labs + 2 optional cloud labs · Updated 14 October 2026 Purpose: learn Terraform…

Read More

Terraform Lab – Azure Hands-On – Terraform Modules

Course level: Beginner → IntermediateCourse type: Tutorial + runnable hands-on exercises + instructor checkpointsUpdated: 14 October 2026Terraform: >= 1.15.0, < 2.0.0 (lab baseline: 1.15.9)AzureRM provider: ~> 5.9.0 (root; child modules declare >= 5.9.0)Azure baseline: eastus, Ubuntu…

Read More

Terraform Lab – Azure Hands-On – Conditional Expressions and Logic

This workbook teaches the complete set of Terraform conditionals, filtering, validation, null handling, and related functions from the requested syllabus. Every numbered concept includes one standalone AWS EC2…

Read More