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 functions, not another infrastructure service. Every lab has short, complete code and a practice task. Azure Linux VM is used only near the end; AWS uses one VPC and one subnet. No resource is created until a learner deliberately runs an optional cloud lab.

Previous-course compatibility: The accompanying Azure variables lab used Terraform 1.15.9, AzureRM 5.9.x, an Azure VNet and a Linux/Windows VM. This course retains that baseline where Azure is deployed, while keeping all the local-only lessons independent. Never overwrite or switch state files between this package and your old 13-resource Azure lab.

Learning objectives

Students will learn to evaluate functions in terraform console, manipulate strings/maps/lists/numbers, guard optional values, render files and cloud-init, handle path/time functions correctly, derive CIDR networks, understand deterministic versus non-deterministic results, and combine functions in safe Azure and AWS labs.

Progression at a glance

flowchart LR
    A[Input values] --> B[Functions and expressions]
    B --> C[Local values and outputs]
    C --> D{Deployment needed?}
    D -->|No| E[Console or plan]
    D -->|Yes| F[Review cloud plan]
    F --> G[Apply then verify]

Module and lab plan

ModuleLabsTopicCloud required?
101–02Introduction to Terraform FunctionsNo
203–05String FunctionsNo
306–08Collection FunctionsNo
409–10Numeric and Mathematical FunctionsNo
511–12Type Conversion, Validation, and Error HandlingNo
613–15File, Encoding, and Data FunctionsNo
716–17Filesystem, Path, and Time FunctionsNo
818–22Advanced Functions and Real-World Use CasesOnly for optional labs 21–22

Tools and prerequisites

  • Terraform CLI: training baseline 1.15.9 (the exercises use >= 1.15.0, < 2.0.0). See Install Terraform.
  • Text editor and terminal (macOS, Linux, Windows/WSL or PowerShell).
  • Labs 01–20: no cloud account, no Azure CLI, no AWS CLI, and no Terraform provider download required except Lab 19, which references Terraform’s bundled built-in terraform provider.
  • Lab 21 only: Azure CLI/credentials, an Azure subscription and quota, a supported Linux VM image/SKU, an existing public SSH key; no password or public IP required.
  • Lab 22 only: configured AWS identity with EC2 network creation permissions.

Standard hands-on workflow (for a local lab)

From the extracted package root, replace <LAB_FOLDER> with a directory inside labs/:

terraform -version
terraform -chdir=labs/<LAB_FOLDER> init -backend=false
terraform -chdir=labs/<LAB_FOLDER> fmt
terraform -chdir=labs/<LAB_FOLDER> validate
terraform -chdir=labs/<LAB_FOLDER> plan -input=false
terraform -chdir=labs/<LAB_FOLDER> apply -auto-approve
terraform -chdir=labs/<LAB_FOLDER> output
terraform -chdir=labs/<LAB_FOLDER> console

You can type an expression into the console, e.g. lower("PROD"), and type exit when finished. The apply -auto-approve shortcut is only for the local, no-resource lessons. Do not copy it into Labs 21–22. Without apply, outputs can often be examined in plan, but terraform output reads the latest state, not the current configuration by itself. Local plans do not provision cloud resources.

Independent folders are intentional: each lab evolves the running concept without requiring the learner to reuse the same Terraform state; separate directories prevent accidental deletions or changes to the previous labs.

Useful commands for instructors

# Example: Lab 04 (local-only)
terraform -chdir=labs/lab-04-string-transform-and-split init -backend=false
terraform -chdir=labs/lab-04-string-transform-and-split validate
terraform -chdir=labs/lab-04-string-transform-and-split plan

# Run local syntax/init/validate checks (requires installed terraform)
bash tools/validate_labs.sh

# Optional: check cloud labs' provider configuration too; downloads providers
bash tools/validate_labs.sh --include-cloud

Source-file conventions

  • main.tf: standalone minimal lab, usually only locals and output.
  • files/: tiny static JSON/YAML/text fixtures, never secrets.
  • cloud-init.tftpl: small template introduced in Lab 15 and reused in the Azure VM lab.
  • README.md: short runbook beside each lab.
  • Generated .terraform/, state, credentials and saved plans are ignored by .gitignore. Commit .terraform.lock.hcl in real infrastructure repositories to retain provider dependency selections.

Important security and state rule

Your uploaded Azure Part 2 lab is a 13-resource existing-state exercise, covering Windows and Ubuntu VMs. This course is separate. The Azure deployment example intentionally uses a different, five-resource environment and SSH keys instead of copied passwords. Do not place the new main.tf beside your existing terraform.tfstate. If applying a derived expression to your previous deployment, use a new locals/output only, then review a no-replacement plan. See REFERENCE_AZURE_LAB_SAFE_INTEGRATION.md.


Module 1 — Introduction to Terraform Functions

Goal: Build confidence with function calls, variables, expressions, and terraform console.

Terraform includes built-in functions; you call them inside an expression like lower("DEV") or max(1, 2). Functions do not themselves create resources and you cannot define your own general-purpose HCL functions. The output is a value, often used in locals, an output, a variable validation rule, or an attribute of a resource.

  • Variable: input provided by configuration, -var, environment, or a .tfvars file, referenced using var.environment.
  • Expression: a combination of values, references, operators, and calls, e.g. "vm-${lower(var.environment)}".
  • Function: the operation inside an expression, e.g. lower(var.environment).
  • Local value: reusable named expression, e.g. local.resource_name.

Most pure functions can be evaluated as part of planning; some exceptions are non-deterministic functions and plan-context functions. The console is an excellent way to experiment without deploying anything.

Lab 01 — Your first functions in terraform console

Folder: labs/lab-01-console-first-functions/
Functions: lower(), upper(), length()
Objective: Understand a call as function(arguments), then inspect the result without creating resources.

A. Concept and scenario

Functions transform values inside expressions. The terraform console REPL evaluates them interactively; no provider or cloud account is needed. This first config simply mirrors the results as outputs.

B. Create the lab files (complete source code)

main.tf

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

locals {
  name  = lower("AZURE")
  label = "${upper("dev")}-${length("azure")}"
}

output "name"  { value = local.name }
output "label" { value = local.label }

C. Run step by step

terraform -chdir=labs/lab-01-console-first-functions init -backend=false
terraform -chdir=labs/lab-01-console-first-functions fmt
terraform -chdir=labs/lab-01-console-first-functions validate
terraform -chdir=labs/lab-01-console-first-functions plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-01-console-first-functions apply -auto-approve
terraform -chdir=labs/lab-01-console-first-functions output

D. Expected result / checks

lower("AZURE") becomes azure; the label contains DEV-5.

Instructor note: Try lower("AZURE"), max(1, 9, 2), and length(["a","b"]) in terraform console before planning.

E. Practice task

Run terraform console and calculate upper("linux") and length("terraform") yourself.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 02 — Variables, expressions, and function evaluation

Folder: labs/lab-02-variables-vs-functions/
Functions: var.*, trimspace(), lower(), format()
Objective: Distinguish input values from computed expressions; use a function to make a predictable name.

A. Concept and scenario

A variable receives a value; an expression evaluates references/operators/functions; a function returns a result. locals names the derived result. No resource is required to understand any of these.

B. Create the lab files (complete source code)

main.tf

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

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

locals {
  clean_project = lower(trimspace(var.project))
  resource_name = format("vm-%s-%s", local.clean_project, var.environment)
}

output "original_input" { value = var.project }
output "resource_name"  { value = local.resource_name }

C. Run step by step

terraform -chdir=labs/lab-02-variables-vs-functions init -backend=false
terraform -chdir=labs/lab-02-variables-vs-functions fmt
terraform -chdir=labs/lab-02-variables-vs-functions validate
terraform -chdir=labs/lab-02-variables-vs-functions plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-02-variables-vs-functions apply -auto-approve
terraform -chdir=labs/lab-02-variables-vs-functions output

D. Expected result / checks

resource_name becomes vm-webapp-dev.

E. Practice task

Change project to Billing Portal and predict the generated VM name before planning.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 2 — String Functions

Goal: Clean names and generate predictable configuration text.

Use short chains rather than complicated nested expressions. A typical safe pipeline is lower(replace(trimspace(var.project), " ", "-")). A normalized string is not automatically a valid resource name: provider-specific naming, max length, reserved words, and uniqueness requirements still apply.

trim takes a cutset rather than a literal string. regex raises an error when nothing matches, whereas regexall returns an empty list. Prefer startswith, endswith, contains, replace or other simpler functions instead of regular expressions where possible.

Lab 03 — Case, trimming, and newline cleanup

Folder: labs/lab-03-string-case-cleanup/
Functions: lower(), upper(), title(), trim(), trimspace(), chomp()
Objective: Normalize environment labels, an Azure-style resource name, and a copied string.

A. Concept and scenario

trim(value, cutset) removes characters belonging to the supplied cutset from both ends, not a literal prefix; trimspace removes surrounding Unicode whitespace; chomp removes newline endings.

B. Create the lab files (complete source code)

main.tf

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

locals {
  clean_environment = lower(trimspace("  PRODUCTION  "))
  pretty_title      = title("terraform functions")
  shout             = upper("azure linux vm")
  cut_markers       = trim("***dev***", "*")
  clean_line        = chomp("hello\n\n")
}

output "string_results" {
  value = {
    clean_environment = local.clean_environment
    pretty_title = local.pretty_title
    shout = local.shout
    cut_markers = local.cut_markers
    clean_line = local.clean_line
  }
}

C. Run step by step

terraform -chdir=labs/lab-03-string-case-cleanup init -backend=false
terraform -chdir=labs/lab-03-string-case-cleanup fmt
terraform -chdir=labs/lab-03-string-case-cleanup validate
terraform -chdir=labs/lab-03-string-case-cleanup plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-03-string-case-cleanup apply -auto-approve
terraform -chdir=labs/lab-03-string-case-cleanup output

D. Expected result / checks

clean_environment is production, pretty_title is Terraform Functions, clean_line is hello.

E. Practice task

Try trim("---dev---", "-"). Compare chomp("x\n") with trimspace(" x \n") in the console.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 04 — Replace, slice, split, join, and prefix/suffix checks

Folder: labs/lab-04-string-transform-and-split/
Functions: replace(), substr(), split(), join(), startswith(), endswith()
Objective: Turn an application label into an infrastructure-friendly name, and process a simple comma-separated value.

A. Concept and scenario

substr uses a zero-based offset and count. startswith/endswith produce booleans; split produces a list and join rebuilds a string.

B. Create the lab files (complete source code)

main.tf

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

locals {
  raw_name   = "My Linux VM"
  slug       = lower(replace(local.raw_name, " ", "-"))
  short_name = substr("terraform", 0, 5)
  regions    = split(",", "eastus,westeurope,japaneast")
  label      = join("-", ["vm", "demo", "dev"])
  is_vm      = startswith(local.label, "vm-")
  is_dev     = endswith(local.label, "-dev")
}

output "transforms" {
  value = {
    raw_name = local.raw_name
    slug = local.slug
    short_name = local.short_name
    regions = local.regions
    label = local.label
    is_vm = local.is_vm
    is_dev = local.is_dev
  }
}

C. Run step by step

terraform -chdir=labs/lab-04-string-transform-and-split init -backend=false
terraform -chdir=labs/lab-04-string-transform-and-split fmt
terraform -chdir=labs/lab-04-string-transform-and-split validate
terraform -chdir=labs/lab-04-string-transform-and-split plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-04-string-transform-and-split apply -auto-approve
terraform -chdir=labs/lab-04-string-transform-and-split output

D. Expected result / checks

slug is my-linux-vm, short_name is terra, regions has three elements, and both checks are true.

E. Practice task

Change raw_name to Data Service; add "japaneast" to the region list.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 05 — Formatting values and finding text patterns

Folder: labs/lab-05-formatting-and-regex/
Functions: format(), formatlist(), regex(), regexall()
Objective: Generate consistent VM labels and extract parts of a resource identifier.

A. Concept and scenario

format returns one string; formatlist applies one format across each list element. regex errors when no match exists; regexall returns an empty list if there are no matches. Use a simpler dedicated function when possible.

B. Create the lab files (complete source code)

main.tf

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

locals {
  single       = format("vm-%s-%02d", "dev", 3)
  several      = formatlist("node-%02d", [1, 2, 3])
  first_word   = regex("[a-z]+", "rg-prod-03")
  digit_groups = regexall("[0-9]+", "vm12-zone3")
  is_valid     = length(regexall("^[a-z][a-z0-9-]*$", "my-linux-vm")) > 0
}

output "formatted" {
  value = {
    single = local.single
    several = local.several
    first_word = local.first_word
    digit_groups = local.digit_groups
    is_valid = local.is_valid
  }
}

C. Run step by step

terraform -chdir=labs/lab-05-formatting-and-regex init -backend=false
terraform -chdir=labs/lab-05-formatting-and-regex fmt
terraform -chdir=labs/lab-05-formatting-and-regex validate
terraform -chdir=labs/lab-05-formatting-and-regex plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-05-formatting-and-regex apply -auto-approve
terraform -chdir=labs/lab-05-formatting-and-regex output

D. Expected result / checks

single is vm-dev-03; several contains three node labels; digit_groups is ["12", "3"].

E. Practice task

Try regexall("[0-9]+", "no-digits"). Why is this safer than regex for counting matches?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 3 — Collection Functions

Goal: Combine, normalize and select list/set/map values without resources.

Lists are ordered; sets contain unique values and have no promised ordering; maps associate string keys with values. tolist(toset(x)) removes duplicate items but does not guarantee a consistent order; use sort(tolist(toset(x))) when stable naming or plan rendering matters.

element has intentional wrap-around behavior; direct [index] access is usually clearer. lookup(map,key,default) avoids missing-key errors, and later arguments to merge override the earlier map values. matchkeys is useful for parallel lists but is often less readable than a for expression.

Lab 06 — Count, access, join, and flatten collections

Folder: labs/lab-06-list-basics/
Functions: length(), element(), concat(), flatten()
Objective: Work with a simple list of Azure deployment environments and nested collections.

A. Concept and scenario

Lists are zero-indexed. element wraps an out-of-range index (unlike list[index]), so prefer ordinary indexing unless wraparound is intended. flatten removes nesting of directly nested lists.

B. Create the lab files (complete source code)

main.tf

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

locals {
  environments = ["dev", "stage", "prod"]
  count        = length(local.environments)
  wraparound   = element(["eastus", "westus"], 3)
  together     = concat(["dev"], ["stage", "prod"])
  flat         = flatten([["dev", "qa"], ["stage", "prod"]])
}

output "collections" {
  value = {
    environments = local.environments
    count = local.count
    wraparound = local.wraparound
    together = local.together
    flat = local.flat
  }
}

C. Run step by step

terraform -chdir=labs/lab-06-list-basics init -backend=false
terraform -chdir=labs/lab-06-list-basics fmt
terraform -chdir=labs/lab-06-list-basics validate
terraform -chdir=labs/lab-06-list-basics plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-06-list-basics apply -auto-approve
terraform -chdir=labs/lab-06-list-basics output

D. Expected result / checks

count is 3, wraparound is westus, and flat has four strings.

E. Practice task

Why does element(["eastus", "westus"], 3) return the second element?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 07 — Deduplicate, filter blanks, search, and convert types

Folder: labs/lab-07-clean-collections/
Functions: distinct(), compact(), contains(), index(), tolist(), toset(), tomap()
Objective: Clean an input list of environments without for_each or resources.

A. Concept and scenario

Sets remove duplicates and do not promise iteration order. Convert with tolist and sort when a stable order matters. index errors if an item is missing; contains is safer for testing membership.

B. Create the lab files (complete source code)

main.tf

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

locals {
  raw            = ["dev", "", "dev", "prod", null]
  nonempty       = compact(local.raw)
  deduplicated   = distinct(local.nonempty)
  unique_sorted  = sort(tolist(toset(local.nonempty)))
  labels_by_env  = tomap({ dev = "Linux", prod = "Linux" })
  found_prod     = contains(local.deduplicated, "prod")
  prod_index     = index(local.deduplicated, "prod")
}

output "cleaned" {
  value = {
    raw = local.raw
    nonempty = local.nonempty
    deduplicated = local.deduplicated
    unique_sorted = local.unique_sorted
    labels_by_env = local.labels_by_env
    found_prod = local.found_prod
    prod_index = local.prod_index
  }
}

C. Run step by step

terraform -chdir=labs/lab-07-clean-collections init -backend=false
terraform -chdir=labs/lab-07-clean-collections fmt
terraform -chdir=labs/lab-07-clean-collections validate
terraform -chdir=labs/lab-07-clean-collections plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-07-clean-collections apply -auto-approve
terraform -chdir=labs/lab-07-clean-collections output

D. Expected result / checks

deduplicated is ["dev", "prod"]; found_prod is true; prod_index is 1.

E. Practice task

Try index(["dev","prod"], "stage") in the console. How could you avoid the error?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 08 — Look up, merge, zip, match and order data

Folder: labs/lab-08-maps-and-ordering/
Functions: keys(), values(), lookup(), merge(), zipmap(), matchkeys(), sort(), reverse()
Objective: Build a reusable environment-to-VM-size map and select values by key.

A. Concept and scenario

merge uses values from the later map when a key repeats. keys and values return results sorted by key, while matchkeys(values, keys, searchset) keeps matching positions.

B. Create the lab files (complete source code)

main.tf

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

locals {
  sizes        = zipmap(["dev", "prod"], ["Standard_B1s", "Standard_B2s_v2"])
  size         = lookup(local.sizes, "stage", "Standard_B1s")
  all_keys     = keys(local.sizes)
  all_values   = values(local.sizes)
  common_tags  = merge({ team = "platform", env = "dev" }, { env = "prod" })
  matched      = matchkeys(["vm-a", "vm-b", "vm-c"], ["dev", "prod", "dev"], ["dev"])
  ascending    = sort(["prod", "dev", "stage"])
  descending   = reverse(local.ascending)
}

output "map_results" {
  value = {
    sizes = local.sizes
    size = local.size
    all_keys = local.all_keys
    all_values = local.all_values
    common_tags = local.common_tags
    matched = local.matched
    ascending = local.ascending
    descending = local.descending
  }
}

C. Run step by step

terraform -chdir=labs/lab-08-maps-and-ordering init -backend=false
terraform -chdir=labs/lab-08-maps-and-ordering fmt
terraform -chdir=labs/lab-08-maps-and-ordering validate
terraform -chdir=labs/lab-08-maps-and-ordering plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-08-maps-and-ordering apply -auto-approve
terraform -chdir=labs/lab-08-maps-and-ordering output

D. Expected result / checks

size is Standard_B1s; matched contains ["vm-a", "vm-c"].

E. Practice task

Change the requested environment to prod. Predict lookup with an unknown key and a default value.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 4 — Numeric and Mathematical Functions

Goal: Calculate capacities, numeric bounds and sequences.

Use math for infrastructure policies where the algorithm is obvious, e.g. ceil(var.expected_users / 50) for a demonstration VM count. Real sizing requires memory, CPU and load measurement, so the quotient is an educational model rather than an AWS/Azure sizing recommendation.

max and min accept numbers as individual arguments; when starting from a list use expansion syntax like max([1, 4, 3]...). range can be used to generate list indices, but Terraform limits the maximum size of generated ranges; don’t use it to create an uncontrolled number of resources.

Lab 09 — Normalize and compare numeric values

Folder: labs/lab-09-numeric-foundations/
Functions: abs(), ceil(), floor(), max(), min(), parseint(), signum()
Objective: Round estimated capacity, compare resource counts, and convert number strings.

A. Concept and scenario

ceil rounds up, floor rounds down; parseint takes a digit string and its radix; signum returns -1, 0, or 1.

B. Create the lab files (complete source code)

main.tf

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

variable "expected_users" {
  type    = number
  default = 120
}

locals {
  required_vms  = ceil(var.expected_users / 50)
  absolute      = abs(-7)
  rounded_down  = floor(2.9)
  biggest       = max(3, 8, 5)
  smallest      = min(3, 8, 5)
  hex_to_number = parseint("ff", 16)
  direction     = signum(-42)
}

output "math_results" {
  value = {
    required_vms = local.required_vms
    absolute = local.absolute
    rounded_down = local.rounded_down
    biggest = local.biggest
    smallest = local.smallest
    hex_to_number = local.hex_to_number
    direction = local.direction
  }
}

C. Run step by step

terraform -chdir=labs/lab-09-numeric-foundations init -backend=false
terraform -chdir=labs/lab-09-numeric-foundations fmt
terraform -chdir=labs/lab-09-numeric-foundations validate
terraform -chdir=labs/lab-09-numeric-foundations plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-09-numeric-foundations apply -auto-approve
terraform -chdir=labs/lab-09-numeric-foundations output

D. Expected result / checks

required_vms is 3 for 120 users at 50 users per VM; hex_to_number is 255.

E. Practice task

Change expected_users to 151; predict the new capacity. What should happen if users is negative?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 10 — Powers, logarithms, totals, and number ranges

Folder: labs/lab-10-math-sequences/
Functions: log(), pow(), sum(), range()
Objective: Calculate a sample capacity series and a simple scale-up estimate.

A. Concept and scenario

log(x, base) calculates a logarithm; pow(x, y) raises to a power; sum totals a collection of numbers; range(start, limit, step) stops before the limit and has a maximum output size.

B. Create the lab files (complete source code)

main.tf

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

locals {
  steps          = range(0, 4)
  powers_of_two  = [for i in local.steps : pow(2, i)]
  total_capacity = sum(local.powers_of_two)
  log_example    = log(100, 10)
  odd_indices    = range(1, 7, 2)
}

output "math_results" {
  value = {
    steps = local.steps
    powers_of_two = local.powers_of_two
    total_capacity = local.total_capacity
    log_example = local.log_example
    odd_indices = local.odd_indices
  }
}

C. Run step by step

terraform -chdir=labs/lab-10-math-sequences init -backend=false
terraform -chdir=labs/lab-10-math-sequences fmt
terraform -chdir=labs/lab-10-math-sequences validate
terraform -chdir=labs/lab-10-math-sequences plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-10-math-sequences apply -auto-approve
terraform -chdir=labs/lab-10-math-sequences output

D. Expected result / checks

powers_of_two is [1, 2, 4, 8], total_capacity is 15, odd_indices is [1,3,5].

E. Practice task

Use range(1, 8, 2) to create odd numbers. Why should you avoid unbounded huge ranges?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 5 — Type Conversion, Validation, and Error Handling

Goal: Safely handle optional values and invalid input.

Terraform already performs many type conversions automatically. Explicit tostring, tonumber, and tobool help when normalizing values read from JSON/YAML or returned by providers. tobool accepts only booleans, null, and the strings "true" and "false".

try() and can() are for dynamic errors, not syntax mistakes; use try for a fallback result and can for a boolean check. coalesce skips null and empty-string arguments; coalescelist skips empty lists. one is appropriate when a collection should have at most one item.

sensitive() marks a value for redaction in normal Terraform UI output. It does not encrypt or remove it from state and saved plans. nonsensitive() deliberately removes that mark, and therefore must never be used casually on an actual credential.

flowchart TD
  A[Optional input] --> B{Value present and valid?}
  B -->|Yes| C[Use input]
  B -->|No| D[Use try / lookup / coalesce]
  D --> E[Validate final input]
  C --> E
  E --> F[Derived configuration]

Lab 11 — Conversions, safe lookups, and nulls

Folder: labs/lab-11-conversion-and-fallbacks/
Functions: tostring(), tonumber(), tobool(), try(), can(), coalesce(), coalescelist(), one()
Objective: Handle optional settings and data read from text without creating any resources.

A. Concept and scenario

try catches dynamically arising errors, not a completely invalid static expression. can is useful in variable validation. coalesce skips null and empty strings; coalescelist picks the first nonempty list; one allows zero or one item only.

B. Create the lab files (complete source code)

main.tf

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

locals {
  settings   = { environment = "dev" }
  count_text = tostring(3)
  count_num  = tonumber("3")
  enabled    = tobool("true")
  size       = try(local.settings.instance.size, "Standard_B1s")
  has_number = can(tonumber("not-a-number"))
  primary    = coalesce(null, "", "ops@example.com")
  zones      = coalescelist([], ["zone-a", "zone-b"])
  only_item  = one(["web"])
  no_item    = one([])
}

output "safe_values" {
  value = {
    settings = local.settings
    count_text = local.count_text
    count_num = local.count_num
    enabled = local.enabled
    size = local.size
    has_number = local.has_number
    primary = local.primary
    zones = local.zones
    only_item = local.only_item
    no_item = local.no_item
  }
}

C. Run step by step

terraform -chdir=labs/lab-11-conversion-and-fallbacks init -backend=false
terraform -chdir=labs/lab-11-conversion-and-fallbacks fmt
terraform -chdir=labs/lab-11-conversion-and-fallbacks validate
terraform -chdir=labs/lab-11-conversion-and-fallbacks plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-11-conversion-and-fallbacks apply -auto-approve
terraform -chdir=labs/lab-11-conversion-and-fallbacks output

D. Expected result / checks

size is Standard_B1s, has_number is false, primary is ops@example.com, only_item is web.

E. Practice task

Test tobool("yes") and one(["dev","prod"]) in the console. Explain why they fail.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 12 — Variable validation and safe sensitive marking

Folder: labs/lab-12-validation-and-sensitive/
Functions: can(), regex(), contains(), sensitive(), nonsensitive()
Objective: Validate environment choices and demonstrate that sensitive marking affects CLI display, not state secrecy.

A. Concept and scenario

validation blocks reject invalid inputs. A value passed into sensitive() is redacted in normal CLI output, but may still be present in state. nonsensitive() must only be used after intentionally proving the value safe for exposure.

B. Create the lab files (complete source code)

main.tf

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

variable "environment" {
  type    = string
  default = "dev"
  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage, or prod."
  }
}

variable "application" {
  type    = string
  default = "linuxapp"
  validation {
    condition     = can(regex("^[a-z][a-z0-9-]+$", var.application))
    error_message = "application must contain lowercase letters, digits or hyphens."
  }
}

locals {
  demo_sensitive = sensitive("TRAINING-ONLY-NOT-A-REAL-SECRET")
  safe_public    = nonsensitive(sensitive("public-label"))
}

output "validated_name" { value = "${var.application}-${var.environment}" }
output "public_label"   { value = local.safe_public }
output "training_secret" {
  value     = local.demo_sensitive
  sensitive = true
}

C. Run step by step

terraform -chdir=labs/lab-12-validation-and-sensitive init -backend=false
terraform -chdir=labs/lab-12-validation-and-sensitive fmt
terraform -chdir=labs/lab-12-validation-and-sensitive validate
terraform -chdir=labs/lab-12-validation-and-sensitive plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-12-validation-and-sensitive apply -auto-approve
terraform -chdir=labs/lab-12-validation-and-sensitive output

D. Expected result / checks

environment is dev; the secret-like training output is shown as sensitive. Changing environment to invalid fails validation.

E. Practice task

Run terraform plan -var="environment=invalid". Do not use real secrets in examples or commit state.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 6 — File, Encoding, and Data Functions

Goal: Work with static files, JSON/YAML and cloud-init payloads.

file and templatefile read pre-existing files; Terraform functions do not participate in the resource dependency graph, so they cannot reliably read files a resource creates during the same operation. Always prefer paths built with ${path.module}.

file decodes UTF-8 text, while filebase64 reads bytes and Base64-encodes them. base64decode expects Base64-encoded UTF-8 text; textdecodebase64(text, "UTF-8") specifies the decoding character set. Use jsonencode/yamlencode rather than manual string concatenation for structured configuration. For AzureRM azurerm_linux_virtual_machine.custom_data, wrap cloud-init text in base64encode(...).

Lab 13 — Read local files and encode text safely

Folder: labs/lab-13-read-files-and-encode/
Functions: file(), fileexists(), filebase64(), base64encode(), base64decode(), textdecodebase64()
Objective: Read one pre-existing text file, test existence, and compare text and byte-oriented Base64 functions.

A. Concept and scenario

file returns UTF-8 text. filebase64 encodes the original bytes, including newlines. textdecodebase64 requires an explicit character encoding. Files must exist before Terraform starts.

B. Create the lab files (complete source code)

main.tf

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

locals {
  text        = chomp(file("${path.module}/files/greeting.txt"))
  exists      = fileexists("${path.module}/files/greeting.txt")
  missing     = fileexists("${path.module}/files/not-here.txt")
  base64_text = base64encode("hello")
  decoded     = base64decode(local.base64_text)
  file_bytes  = filebase64("${path.module}/files/greeting.txt")
  roundtrip   = textdecodebase64(local.file_bytes, "UTF-8")
}

output "file_results" {
  value = {
    text = local.text
    exists = local.exists
    missing = local.missing
    base64_text = local.base64_text
    decoded = local.decoded
    file_bytes = local.file_bytes
    roundtrip = local.roundtrip
  }
}

files/greeting.txt

hello

C. Run step by step

terraform -chdir=labs/lab-13-read-files-and-encode init -backend=false
terraform -chdir=labs/lab-13-read-files-and-encode fmt
terraform -chdir=labs/lab-13-read-files-and-encode validate
terraform -chdir=labs/lab-13-read-files-and-encode plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-13-read-files-and-encode apply -auto-approve
terraform -chdir=labs/lab-13-read-files-and-encode output

D. Expected result / checks

exists is true, missing is false, base64_text is aGVsbG8= and decoded is hello.

E. Practice task

Change files/greeting.txt to include a second line; observe Base64 output versus chomp(file(...)).

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 14 — Decode and encode JSON and YAML configuration

Folder: labs/lab-14-json-and-yaml/
Functions: jsonencode(), jsondecode(), yamlencode(), yamldecode(), file()
Objective: Read two tiny application configs and produce serialized content for cloud automation.

A. Concept and scenario

jsondecode and yamldecode return Terraform values (objects/lists/scalars); the encoding functions produce strings. jsonencode is suitable for structured JSON, avoiding hand-built JSON text.

B. Create the lab files (complete source code)

main.tf

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

locals {
  app          = jsondecode(file("${path.module}/files/config.json"))
  settings     = yamldecode(file("${path.module}/files/config.yaml"))
  application  = local.app.name
  environment  = local.settings.environment
  json_output  = jsonencode({ name = local.application, env = local.environment })
  yaml_output  = yamlencode({ name = local.application, port = local.settings.port })
}

output "application"  { value = local.application }
output "environment"  { value = local.environment }
output "json_string"  { value = local.json_output }
output "yaml_string"  { value = local.yaml_output }

files/config.json

{"name":"web","team":"platform","replicas":2}

files/config.yaml

environment: dev
port: 8080

C. Run step by step

terraform -chdir=labs/lab-14-json-and-yaml init -backend=false
terraform -chdir=labs/lab-14-json-and-yaml fmt
terraform -chdir=labs/lab-14-json-and-yaml validate
terraform -chdir=labs/lab-14-json-and-yaml plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-14-json-and-yaml apply -auto-approve
terraform -chdir=labs/lab-14-json-and-yaml output

D. Expected result / checks

application is web, environment is dev, and YAML port is 8080.

E. Practice task

Change config.json to add a tag, and add an output for it. Compare structured output with serialized JSON output.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 15 — Render a cloud-init template and encode its payload

Folder: labs/lab-15-templatefile-cloud-init/
Functions: templatefile(), base64encode(), fileexists()
Objective: Connect functions to a real-world bootstrap script while remaining cloud-free.

A. Concept and scenario

templatefile(path, vars) renders a UTF-8 template from a file that exists before the run. Use explicit variable names in the vars object. Azure Linux VM custom_data expects Base64 in its newer VM resource. This lab only previews the payload.

B. Create the lab files (complete source code)

main.tf

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

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

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

output "cloud_init"       { value = local.cloud_init }
output "encoded_cloud_init" { value = base64encode(local.cloud_init) }
output "template_exists"  { value = fileexists("${path.module}/cloud-init.tftpl") }

cloud-init.tftpl

#cloud-config
write_files:
  - path: /etc/functions-training.conf
    permissions: '0644'
    content: |
      environment=${environment}
      application=${app_name}

C. Run step by step

terraform -chdir=labs/lab-15-templatefile-cloud-init init -backend=false
terraform -chdir=labs/lab-15-templatefile-cloud-init fmt
terraform -chdir=labs/lab-15-templatefile-cloud-init validate
terraform -chdir=labs/lab-15-templatefile-cloud-init plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-15-templatefile-cloud-init apply -auto-approve
terraform -chdir=labs/lab-15-templatefile-cloud-init output

D. Expected result / checks

The rendered YAML starts with #cloud-config and writes a file containing environment=dev.

E. Practice task

Change the environment to stage; inspect terraform output -raw cloud_init. Explain why base64encode is needed for the Azure Linux VM resource.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 7 — Filesystem, Path, and Time Functions

Goal: Use portable paths and understand time-dependent values.

path.module is the directory containing the current module; path.root is the root module; path.cwd is Terraform’s original working directory, which may differ when -chdir is used. abspath and pathexpand depend on the machine running Terraform, so do not embed their results in shared resource names or remote identifiers.

plantimestamp() returns a fixed timestamp within a plan; it changes on later plans and is not available in the console. timestamp() returns an apply-time current value and is unknown in plan. The two are not interchangeable, and neither should be attached to an otherwise stable resource name.

Lab 16 — Filesystem path functions and globbed file sets

Folder: labs/lab-16-paths-and-fileset/
Functions: abspath(), dirname(), basename(), pathexpand(), fileset(), path.module, path.root, path.cwd
Objective: Use filesystem functions predictably, resolving files relative to the module rather than to the shell working directory.

A. Concept and scenario

path.module locates the current module, path.root the root module and path.cwd the original working directory. Absolute/home-expanded paths vary across learners, so never use them as stable infrastructure identifiers.

B. Create the lab files (complete source code)

main.tf

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

locals {
  example_path  = "${path.module}/files/dev.yaml"
  absolute_path = abspath(local.example_path)
  directory     = dirname(local.example_path)
  file_name     = basename(local.example_path)
  home_example  = pathexpand("~/.ssh/id_ed25519.pub")
  yaml_files    = sort(tolist(fileset("${path.module}/files", "*.yaml")))
  module_path   = path.module
  root_path     = path.root
  cwd_path      = path.cwd
}

output "path_results" {
  value = {
    example_path = local.example_path
    absolute_path = local.absolute_path
    directory = local.directory
    file_name = local.file_name
    home_example = local.home_example
    yaml_files = local.yaml_files
    module_path = local.module_path
    root_path = local.root_path
    cwd_path = local.cwd_path
  }
}

files/dev.yaml

environment: dev

files/prod.yaml

environment: prod

C. Run step by step

terraform -chdir=labs/lab-16-paths-and-fileset init -backend=false
terraform -chdir=labs/lab-16-paths-and-fileset fmt
terraform -chdir=labs/lab-16-paths-and-fileset validate
terraform -chdir=labs/lab-16-paths-and-fileset plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-16-paths-and-fileset apply -auto-approve
terraform -chdir=labs/lab-16-paths-and-fileset output

D. Expected result / checks

yaml_files contains ["dev.yaml", "prod.yaml"] (sorted); basename is dev.yaml.

E. Practice task

From the parent folder run terraform -chdir=<lab> plan. Why is ${path.module} safer than a relative string alone?

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 17 — Time calculations and plan-time versus apply-time values

Folder: labs/lab-17-time-and-plan-timestamp/
Functions: timestamp(), timeadd(), plantimestamp(), formatdate()
Objective: Distinguish stable date transformations from values that change between Terraform runs.

A. Concept and scenario

timestamp() is unknown during plan and changes each apply; plantimestamp() is fixed for a plan but differs between plans and cannot be evaluated in terraform console. Do not use either to form stable resource names or tags. The static time calculation is deterministic.

B. Create the lab files (complete source code)

main.tf

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

locals {
  fixed_time    = "2026-10-14T10:00:00Z"
  tomorrow      = timeadd(local.fixed_time, "24h")
  readable_date = formatdate("YYYY-MM-DD", local.fixed_time)
}

output "tomorrow"      { value = local.tomorrow }
output "readable_date" { value = local.readable_date }
output "plan_time"     { value = plantimestamp() }

C. Run step by step

terraform -chdir=labs/lab-17-time-and-plan-timestamp init -backend=false
terraform -chdir=labs/lab-17-time-and-plan-timestamp fmt
terraform -chdir=labs/lab-17-time-and-plan-timestamp validate
terraform -chdir=labs/lab-17-time-and-plan-timestamp plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-17-time-and-plan-timestamp apply -auto-approve
terraform -chdir=labs/lab-17-time-and-plan-timestamp output

D. Expected result / checks

tomorrow is 2026-10-15T10:00:00Z and readable_date is 2026-10-14.

Instructor note: plantimestamp() is not supported inside console; use terraform plan. timestamp() is demonstrated in the console only.

E. Practice task

Inspect terraform plan twice and compare plan time; then run timestamp() in console. Do not try plantimestamp() in console.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Module 8 — Advanced Functions and Real-World Use Cases

Goal: Compose functions for cloud addressing, digests, stable names, and optional Azure/AWS resources.

cidrsubnet and cidrsubnets produce network ranges; once those ranges are assigned to real networks, changing the inputs may force subnet replacement and disrupt connectivity. Use sha256/md5 for checksums and change detection, not for password security. uuid() creates a random value on apply, while uuidv5 produces a stable name-based UUID.

Provider-defined functions use provider::<local-name>::<function>. Unlike most built-in functions, they require a provider declaration. The built-in terraform provider’s encode_tfvars function is a good beginner demonstration requiring no cloud provider download.

The last three labs separate local Azure preview, optional live Azure Ubuntu VM, and optional AWS VPC/subnet. They are deliberately separate from your earlier 13-resource Azure variables lab to protect existing Terraform state.

Lab 18 — CIDR math, digests, and stable unique identifiers

Folder: labs/lab-18-cidr-hashes-and-uuids/
Functions: cidrsubnet(), cidrsubnets(), md5(), sha256(), filemd5(), uuid(), uuidv5()
Objective: Derive Azure/AWS subnet addresses and compute stable fingerprints.

A. Concept and scenario

cidrsubnet selects one numbered child network; cidrsubnets allocates consecutive child networks. md5 and sha256 produce digests, not encryption; never hash passwords as a substitute for password hashing. uuid() is non-deterministic at apply; uuidv5 derives a stable value from a namespace and name.

B. Create the lab files (complete source code)

main.tf

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

variable "network_cidr" {
  type    = string
  default = "10.20.0.0/16"
}

locals {
  subnet_one  = cidrsubnet(var.network_cidr, 8, 1)
  subnet_pair = cidrsubnets(var.network_cidr, 8, 8)
  text_md5    = md5("hello")
  text_sha256 = sha256("hello")
  file_md5    = filemd5("${path.module}/files/release.txt")
  stable_uuid = uuidv5("dns", "app.example.org")
}

output "network_and_identity" {
  value = {
    subnet_one = local.subnet_one
    subnet_pair = local.subnet_pair
    text_md5 = local.text_md5
    text_sha256 = local.text_sha256
    file_md5 = local.file_md5
    stable_uuid = local.stable_uuid
  }
}

files/release.txt

release=v1

C. Run step by step

terraform -chdir=labs/lab-18-cidr-hashes-and-uuids init -backend=false
terraform -chdir=labs/lab-18-cidr-hashes-and-uuids fmt
terraform -chdir=labs/lab-18-cidr-hashes-and-uuids validate
terraform -chdir=labs/lab-18-cidr-hashes-and-uuids plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-18-cidr-hashes-and-uuids apply -auto-approve
terraform -chdir=labs/lab-18-cidr-hashes-and-uuids output

D. Expected result / checks

subnet_one is 10.20.1.0/24; subnet_pair is ["10.20.0.0/24","10.20.1.0/24"]; the digest and stable UUID do not change between runs.

E. Practice task

Change the CIDR root from 10.20.0.0/16 to 10.30.0.0/16. Compare uuid() in console with uuidv5("dns", "demo.example.org").

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 19 — First provider-defined function (built-in terraform provider)

Folder: labs/lab-19-provider-defined-functions/
Functions: provider::terraform::encode_tfvars()
Objective: Call an actual provider-defined function without installing Azure or AWS providers.

A. Concept and scenario

Provider-defined functions use provider::<local-provider-name>::<function>. The built-in Terraform provider has encode_tfvars; its text formatting is not a public interchange format, so use jsonencode for JSON APIs instead.

B. Create the lab files (complete source code)

main.tf

terraform {
  required_version = ">= 1.15.0, < 2.0.0"
  required_providers {
    terraform = {
      source = "terraform.io/builtin/terraform"
    }
  }
}

locals {
  settings = {
    application = "demo"
    replicas    = 2
  }
}

output "tfvars_text" { value = provider::terraform::encode_tfvars(local.settings) }
output "json_text"   { value = jsonencode(local.settings) }

C. Run step by step

terraform -chdir=labs/lab-19-provider-defined-functions init -backend=false
terraform -chdir=labs/lab-19-provider-defined-functions fmt
terraform -chdir=labs/lab-19-provider-defined-functions validate
terraform -chdir=labs/lab-19-provider-defined-functions plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-19-provider-defined-functions apply -auto-approve
terraform -chdir=labs/lab-19-provider-defined-functions output

D. Expected result / checks

The result is text with application and replicas assignments, compatible with .tfvars syntax.

E. Practice task

Add enabled = true to the settings object. Compare provider::terraform::encode_tfvars(...) with jsonencode(...).

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 20 — Azure Linux VM: calculate safe names and CIDRs before deployment

Folder: labs/lab-20-azure-vm-preview/
Functions: lower(), trimspace(), replace(), format(), merge(), cidrsubnet(), lookup(), jsonencode()
Objective: Prepare values for an Azure Ubuntu VM using only local expressions and outputs; zero Azure resources.

A. Concept and scenario

This preview is a safe preparation step for Lab 21. It is separate from the uploaded 13-resource variables lab so it cannot accidentally operate on the previous state. Keep the original names unchanged when adapting into an already-applied training environment.

B. Create the lab files (complete source code)

main.tf

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

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

locals {
  prefix     = lower(replace(trimspace(var.project), " ", "-"))
  base_name  = format("%s-%s", local.prefix, var.environment)
  rg_name    = format("rg-%s", local.base_name)
  vm_name    = format("vm-%s", local.base_name)
  subnet     = cidrsubnet("10.70.0.0/16", 8, 1)
  vm_size    = lookup({ dev = "Standard_B1s", prod = "Standard_B2s_v2" }, var.environment, "Standard_B1s")
  tags       = merge({ managed_by = "terraform" }, { environment = var.environment })
  config_json = jsonencode({ name = local.vm_name, subnet = local.subnet })
}

output "azure_preview" {
  value = {
    resource_group = local.rg_name
    vm_name        = local.vm_name
    subnet         = local.subnet
    vm_size        = local.vm_size
    tags           = local.tags
    config_json    = local.config_json
  }
}

C. Run step by step

terraform -chdir=labs/lab-20-azure-vm-preview init -backend=false
terraform -chdir=labs/lab-20-azure-vm-preview fmt
terraform -chdir=labs/lab-20-azure-vm-preview validate
terraform -chdir=labs/lab-20-azure-vm-preview plan -input=false
# The following creates NO cloud resources for this lab:
terraform -chdir=labs/lab-20-azure-vm-preview apply -auto-approve
terraform -chdir=labs/lab-20-azure-vm-preview output

D. Expected result / checks

rg_name is rg-funcdemo-dev, vm_name is vm-funcdemo-dev, and subnet is 10.70.1.0/24.

E. Practice task

Change environment to prod and inspect how name, size, and tags change. Do not change existing deployed Azure names merely to add functions.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 21 — Optional Azure Ubuntu VM: apply functions to minimal infrastructure

Folder: labs/lab-21-azure-linux-vm-deploy/
Functions: lower(), replace(), format(), cidrsubnet(), merge(), file(), pathexpand(), templatefile(), base64encode()
Objective: Create a resource group, VNet, subnet, NIC, and private Linux VM with generated cloud-init; then clean up.

A. Concept and scenario

A fully isolated, optional deployment lab: five Azure resources, SSH public-key authentication, no inbound public IP, no NSG, and cloud-init managed with base64encode(templatefile(...)). The VM name, CIDR and tags are derived by functions. Azure authentication/subscription, region/VM SKU/image permissions, and costs are your responsibility. DO NOT copy this full root into the original 13-resource state directory.

B. Create the lab files (complete source code)

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 "region" {
  type    = string
  default = "eastus"
}
variable "environment" {
  type    = string
  default = "dev"
}
variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/id_ed25519.pub"
}

locals {
  name        = format("functions-%s", lower(replace(var.environment, " ", "-")))
  subnet_cidr = cidrsubnet("10.70.0.0/16", 8, 1)
  tags        = merge({ purpose = "terraform-functions-lab" }, { environment = var.environment })
}

resource "azurerm_resource_group" "lab" {
  name     = "rg-${local.name}"
  location = var.region
  tags     = local.tags
}

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

resource "azurerm_subnet" "lab" {
  name                 = "snet-linux"
  resource_group_name  = azurerm_resource_group.lab.name
  virtual_network_name = azurerm_virtual_network.lab.name
  address_prefixes     = [local.subnet_cidr]
}

resource "azurerm_network_interface" "lab" {
  name                = "nic-${local.name}"
  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                  = "vm-${local.name}"
  resource_group_name   = azurerm_resource_group.lab.name
  location              = azurerm_resource_group.lab.location
  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}/cloud-init.tftpl", {
    environment = var.environment
    vm_name     = "vm-${local.name}"
    subnet      = local.subnet_cidr
  }))
  tags = local.tags
}

output "vm_name"   { value = azurerm_linux_virtual_machine.lab.name }
output "vm_id"     { value = azurerm_linux_virtual_machine.lab.id }
output "subnet"    { value = local.subnet_cidr }

cloud-init.tftpl

#cloud-config
write_files:
  - path: /etc/functions-training.conf
    permissions: '0644'
    content: |
      environment=${environment}
      vm=${vm_name}
      subnet=${subnet}

C. Run step by step

  1. Run az login, select your intended subscription, and set ARM_SUBSCRIPTION_ID.
  2. Ensure ~/.ssh/id_ed25519.pub exists; otherwise create a fresh key using ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 (do not overwrite an existing key).
  3. Use the dedicated lab-21 directory. Do not share state with your earlier 13-resource lab.
  4. Run terraform init, fmt, validate and plan -out=lab.tfplan.
  5. Review the five creates, subscription and region. Only then apply lab.tfplan.
  6. Verify with Azure Run Command (no public IP/SSH connection needed), then terraform destroy when finished.
az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
# Create a key ONLY if you do not already have a suitable one:
test -f ~/.ssh/id_ed25519.pub || ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
terraform -chdir=labs/lab-21-azure-linux-vm-deploy init
terraform -chdir=labs/lab-21-azure-linux-vm-deploy fmt
terraform -chdir=labs/lab-21-azure-linux-vm-deploy validate
terraform -chdir=labs/lab-21-azure-linux-vm-deploy plan -out=lab.tfplan
# After manual review:
terraform -chdir=labs/lab-21-azure-linux-vm-deploy apply lab.tfplan
az vm run-command invoke --resource-group rg-functions-dev --name vm-functions-dev \
  --command-id RunShellScript --scripts 'cat /etc/functions-training.conf'
# When finished:
terraform -chdir=labs/lab-21-azure-linux-vm-deploy destroy

If az vm run-command cannot yet read the file, allow cloud-init to finish and check its logs/agent status. This lab provides no public IP; remote login requires your own approved private network access mechanism. Costs accrue for a running VM even if it has no public IP. On PowerShell, set ARM_SUBSCRIPTION_ID using $env:ARM_SUBSCRIPTION_ID = ... instead of export.

D. Expected result / checks

terraform plan proposes five Azure resources in a NEW isolated state; the guest cloud-init writes /etc/functions-training.conf.

Instructor note: Requires az login, ARM_SUBSCRIPTION_ID, a public SSH key, terraform init, and an explicit review before terraform apply. No public IP is provisioned.

E. Practice task

Change only the environment in the new lab and inspect whether Azure proposes replacement. Destroy the isolated lab when finished.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.

Lab 22 — Optional AWS network lab: combine naming and CIDR functions

Folder: labs/lab-22-aws-vpc-subnet-deploy/
Functions: lower(), replace(), format(), cidrsubnet(), merge()
Objective: Create one VPC and one subnet with derived names and CIDR blocks, then destroy them.

A. Concept and scenario

This example intentionally uses only two AWS resource types. AWS credentials, access rights, network quotas and region are required. aws_subnet is associated with the VPC by ID; it does not create an EC2 instance and there is no internet gateway or NAT gateway.

B. Create the lab files (complete source code)

main.tf

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

variable "region" {
  type    = string
  default = "ap-northeast-1"
}
variable "environment" {
  type    = string
  default = "dev"
}
variable "network_cidr" {
  type    = string
  default = "10.80.0.0/16"
}

provider "aws" { region = var.region }

locals {
  prefix      = lower(replace("Functions Lab", " ", "-"))
  name        = format("%s-%s", local.prefix, var.environment)
  subnet_cidr = cidrsubnet(var.network_cidr, 8, 1)
  tags        = merge({ managed_by = "terraform" }, { environment = var.environment })
}

resource "aws_vpc" "lab" {
  cidr_block = var.network_cidr
  tags       = merge(local.tags, { Name = "vpc-${local.name}" })
}

resource "aws_subnet" "lab" {
  vpc_id     = aws_vpc.lab.id
  cidr_block = local.subnet_cidr
  tags       = merge(local.tags, { Name = "subnet-${local.name}" })
}

output "vpc_id"      { value = aws_vpc.lab.id }
output "subnet_cidr" { value = aws_subnet.lab.cidr_block }

C. Run step by step

  1. Configure AWS credentials through your approved IAM/SSO mechanism and confirm the identity using aws sts get-caller-identity.
  2. Run terraform init, fmt, validate, and plan -out=lab.tfplan in the dedicated directory.
  3. Review that Terraform proposes only one VPC and one subnet. Apply manually after approval and destroy afterwards.
aws sts get-caller-identity
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy init
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy fmt
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy validate
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy plan -out=lab.tfplan
# After manual review:
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy apply lab.tfplan
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy output
# When finished:
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy destroy

Do not create this VPC in a production account. No NAT gateway, IGW, EC2 instance, or public subnet is created.

D. Expected result / checks

terraform plan proposes one VPC and one subnet; the subnet CIDR is 10.80.1.0/24.

Instructor note: Requires configured AWS credentials and permissions. Review terraform plan and apply only if permitted. Destroy the isolated network after the lab.

E. Practice task

Change environment from dev to stage, inspect the tag changes, and then clean up using terraform destroy.

Completion check: terraform validate passes (with provider installation where applicable); the calculated output matches the prediction; only explicit optional cloud labs can create infrastructure.


Function reference — quick syntax and use cases

These compact examples are intended for terraform console. Expressions that read files require those files to exist in the current lab. plantimestamp() is not a console function. Provider-defined functions require the provider declaration from Lab 19. The file examples are illustrative paths; use the actual fixture paths in the corresponding lab.

String functions

FunctionExample expressionWhy / important detail
lower()lower("DEV")Convert to lowercase.
upper()upper("dev")Convert to uppercase.
title()title("terraform functions")Title-style casing.
trim()trim("--dev--", "-")Trim a set of edge characters.
trimspace()trimspace(" dev ")Trim surrounding whitespace.
chomp()chomp("hello\n")Remove trailing newline characters.
replace()replace("my vm", " ", "-")Replace substring or regex matches.
substr()substr("terraform", 0, 5)Select a substring using offset and length.
split()split(",", "dev,prod")Separate string into list.
join()join("-", ["vm", "dev"])Join list elements.
startswith()startswith("vm-dev", "vm-")Test prefix.
endswith()endswith("vm-dev", "-dev")Test suffix.
format()format("vm-%02d", 3)Format one string.
formatlist()formatlist("vm-%02d", [1,2])Format a list of strings.
regex()regex("[a-z]+", "vm12")One regex match; error on no match.
regexall()regexall("[0-9]+", "a1b2")Every regex match, or empty list.

Collection functions

FunctionExample expressionWhy / important detail
length()length(["dev", "prod"])Length of list/map/string.
element()element(["a","b"], 3)Wrap-around index.
concat()concat(["a"], ["b"])Concatenate lists.
flatten()flatten([["a"],["b"]])Flatten nested lists.
distinct()distinct(["a","a","b"])Remove duplicate values.
compact()compact(["a","",null])Remove empty string and null elements.
contains()contains(["dev"], "dev")Membership check.
index()index(["a","b"], "b")Index of value; error when missing.
tolist()tolist(toset(["a","b"]))Normalize to list.
toset()toset(["a","a","b"])Normalize to set (unique).
tomap()tomap({ env = "dev" })Normalize to map.
keys()keys({ b = 2, a = 1 })Map keys, lexically sorted.
values()values({ b = 2, a = 1 })Map values ordered by sorted keys.
lookup()lookup({ dev = "small" }, "stage", "small")Read map value with default.
merge()merge({a=1},{a=2})Later maps override matching keys.
zipmap()zipmap(["a"],[1])Build map from parallel lists.
matchkeys()matchkeys(["vm1","vm2"],["dev","prod"],["dev"])Filter parallel lists by key values.
sort()sort(["prod", "dev"])Sort list of strings.
reverse()reverse(["a","b"])Reverse list.

Numeric and mathematical functions

FunctionExample expressionWhy / important detail
abs()abs(-3)Absolute value.
ceil()ceil(2.1)Round up.
floor()floor(2.9)Round down.
max()max(1,3,2)Largest number.
min()min(1,3,2)Smallest number.
parseint()parseint("ff", 16)Parse integer in a radix.
signum()signum(-5)-1, 0 or 1.
log()log(100, 10)Logarithm with base.
pow()pow(2, 3)Exponentiation.
sum()sum([1,2,3])Sum collection values.
range()range(1, 6, 2)List: start inclusive, limit exclusive.

Conversion, validation and error functions

FunctionExample expressionWhy / important detail
tostring()tostring(123)Convert to string.
tonumber()tonumber("123")Convert to number.
tobool()tobool("true")Convert “true” or “false” to boolean.
try()try({}.missing, "default")Return first successful expression.
can()can(tonumber("abc"))Can evaluate expression without error?
coalesce()coalesce(null, "", "dev")First non-null, nonempty string.
coalescelist()coalescelist([], ["dev"])First nonempty list.
one()one(["only"])Only or zero collection element.
sensitive()sensitive("demo")Mark a value as sensitive.
nonsensitive()nonsensitive(sensitive("public"))Unmark only known-safe values.

File, encoding and data functions

FunctionExample expressionWhy / important detail
file()file("${path.module}/files/file.txt")Read UTF-8 text from existing file.
fileexists()fileexists("${path.module}/files/file.txt")Check file existence.
templatefile()templatefile("${path.module}/file.tftpl", { name = "web" })Render existing template with vars.
jsonencode()jsonencode({ name = "web" })Terraform value to JSON text.
jsondecode()jsondecode("{\"name\":\"web\"}")JSON text to Terraform value.
yamlencode()yamlencode({ name = "web" })Terraform value to YAML text.
yamldecode()yamldecode("name: web")YAML text to Terraform value.
base64encode()base64encode("hi")UTF-8 text to Base64.
base64decode()base64decode("aGk=")Base64 into UTF-8 string.
filebase64()filebase64("${path.module}/files/file.txt")Existing file bytes to Base64.
textdecodebase64()textdecodebase64("aGk=", "UTF-8")Base64 decoded with given charset.

Filesystem, path and time functions

FunctionExample expressionWhy / important detail
abspath()abspath("./files")Convert to absolute path.
dirname()dirname("/tmp/example.txt")Return parent directory.
basename()basename("/tmp/example.txt")Return last component.
pathexpand()pathexpand("~/.ssh/id_ed25519.pub")Expand home directory.
timestamp()timestamp()Apply-time timestamp, changes between applies.
timeadd()timeadd("2026-10-14T00:00:00Z", "24h")Add duration.
plantimestamp()plantimestamp()Plan timestamp; not in console.
formatdate()formatdate("YYYY-MM-DD", "2026-10-14T00:00:00Z")Format date with Terraform tokens.
fileset()fileset("${path.module}/files", "*.yaml")Find filenames by glob.

Advanced functions

FunctionExample expressionWhy / important detail
cidrsubnet()cidrsubnet("10.20.0.0/16", 8, 1)Select one new subnet.
cidrsubnets()cidrsubnets("10.20.0.0/16", 8, 8)Allocate consecutive subnet ranges.
md5()md5("hello")MD5 digest of text.
sha256()sha256("hello")SHA-256 digest of text.
filemd5()filemd5("${path.module}/files/release.txt")MD5 of file bytes.
uuid()uuid()Random UUID, not deterministic.
uuidv5()uuidv5("dns", "app.example.org")Stable name-based UUID.
provider::terraform::encode_tfvars()provider::terraform::encode_tfvars({ name = "web" })Built-in provider-defined function; requires provider declaration.

Path symbols are values, not function calls

ValueMeaningExample
path.moduleDirectory of the module whose expression is being evaluatedfile("${path.module}/files/config.json")
path.rootRoot module directory${path.root}/templates
path.cwdWorking directory in which Terraform was started (before -chdir)Diagnostic use only

When to use functions in production

TaskPreferred approachCaution
Make a readable VM nameformat + lower + replaceCloud provider name length/character restrictions still apply
Avoid blank collection valuescompact, distinct, sortSet iteration order cannot be relied on
Choose a default VM sizelookup with an explicit defaultValidate every permitted SKU/region
Calculate subnetscidrsubnet, cidrsubnetsDon’t change assigned CIDR positions in production
Render cloud-inittemplatefile and (for AzureRM Linux VM) base64encodeKeep secrets out of templates/state
Read JSON or YAML configjsondecode(file(...)), yamldecode(file(...))Only pre-existing UTF-8 files
Mask a CLI outputsensitiveStill visible in state to authorized readers
Default missing optional datatry, can, coalesceDon’t hide bad schema errors with blanket fallbacks
Create a stable identifieruuidv5Do not use uuid for resource name stability
Mark the current run timeA purpose-built time provider or external eventtimestamp creates perpetual drift when attached to resource attributes

Troubleshooting and common mistakes

SymptomCauseFix
Invalid function argument for tobool("yes")Only "true", "false", bool and null are convertibleNormalize at input boundary or reject the input
regex fails for an unmatched patternregex does not return nullUse regexall for zero-or-more matches or can(regex(...))
index failsItem does not existCheck with contains or use lookup for maps
one failsCollection has more than one memberMake selection rules explicit
element unexpectedly returns first itemIntentional modulo wrap-aroundUse list[index] for strict indexing
toset seems to reorder outputSets have no guaranteed ordersort(tolist(toset(values)))
merge overwrites a tagRightmost map winsPut overrides intentionally last
file() cannot find a fileRelative to wrong location or not created yet${path.module}/files/file.txt; only static files
file() reports invalid UTF-8Binary data is not UTF-8Use filebase64 for bytes
templatefile() reports missing keyvars object omits an interpolated namePass the key in the object
JSON is malformed after concatenationManual string buildingUse jsonencode
Azure custom_data is invalidUnencoded user data on newer VM resourcebase64encode(templatefile(...))
plantimestamp fails in consoleFunction is plan-onlyRun terraform plan
Diff on each plan after using timestampTime changes every runRemove from stable resource attributes
nonsensitive leaks a valueSensitive mark deliberately removedUse only when the derived value is demonstrably public
VM replacement after refactoring expressionsRenamed a resource or changed a ForceNew propertyRevert the change; inspect terraform plan
Azure provider registration/quota failureSubscription is not configured for chosen region/SKUCheck resource provider, quota and image availability
Azure private VM cannot accept SSH from laptopNo public IP or reachable private routeUse approved VPN/bastion or az vm run-command
Provider init cannot downloadInternet/proxy/registry limitationsVerify connectivity, mirror and provider version; local-only labs don’t need cloud providers

Instructor-led exercises and assessment

Assignment 1 — Standardize infrastructure names (beginner). Starting from Lab 04, add a new input variable team with default Platform Team. Derive a label containing project, team and environment in lowercase with hyphens. Use only lower, trimspace, replace, join and format. No resources. Success: a reproducible name and a readable output; see assessments/solution-01/main.tf.

Assignment 2 — Generate a safe deployment preview (intermediate). Using Lab 20 as a reference, derive an Azure resource group name, two non-overlapping /24 CIDRs from a /16, a stable tag map and a JSON representation of that map. Use cidrsubnets, merge, lower, replace, jsonencode. No Azure deployment. Success: terraform validate and a plan displaying both CIDRs; see assessments/solution-02/main.tf.

Assignment 3 — Explain, don’t just run (discussion). Why are these patterns risky? uuid() in a resource name; timestamp() in tags; nonsensitive(var.api_key); file("./build/generated.json") where the file is produced by a Terraform resource; and tolist(toset(...)) used for subnet ordering.

Knowledge check and answer key

  1. Can Terraform functions create resources? No; they transform or read values used elsewhere in configuration.
  2. What does try return? The first expression that does not raise a dynamic evaluation error.
  3. Does sensitive encrypt state? No; it controls Terraform’s handling/display of the sensitivity mark.
  4. Why prefer jsonencode over manually assembled JSON text? It correctly serializes nested data, quotes and escaping.
  5. How do timestamp and plantimestamp differ? Apply-time unpredictable result vs. plan-time fixed result; both vary across runs, and plantimestamp cannot be evaluated in the console.
  6. Can you reliably read a file created by another resource during the same apply using file()? No.
  7. Why does sort(tolist(toset(...))) matter? It removes duplicates and guarantees sorted list output, rather than depending on set iteration order.
  8. When to use uuidv5? When a repeatable ID is needed from the same namespace and name.
  9. How should students run optional cloud labs? Authenticate, use a separate state, run init/validate, carefully review the plan, apply deliberately, verify, then destroy.
  10. What is the safest way to introduce new function outputs into the uploaded Azure variables lab? Add locals and output only, preserve existing resource names/values/state, and review for unexpected changes.

Validation and cleanup

# Check local labs (requires Terraform CLI; never performs an apply)
bash tools/validate_labs.sh

# Optional: include AWS/Azure providers (provider downloads required)
bash tools/validate_labs.sh --include-cloud

# Destroy the optional cloud lessons **only if you created them**
terraform -chdir=labs/lab-21-azure-linux-vm-deploy destroy
terraform -chdir=labs/lab-22-aws-vpc-subnet-deploy destroy

Do not run destroy in the old Azure lab folder: its original 13 resources and state are unrelated to these independent examples. State and saved Terraform plan files may contain sensitive values; don’t commit or share them. These files are training examples and cloud deployment results depend on subscription, region, quotas, provider versions and permissions.

Official reference documentation

Last reviewed for this course: 14 October 2026. Actual cloud applies were not performed while generating the course.

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 Template

Level: Beginner to IntermediateFormat: Tutorial + concept guide + practical lab workbookModules: 6 | Labs: 12 | Cloud emphasis: Azure Linux VM, AWS EC2 comparisonBaseline: Terraform 1.15.9+ (within 1.x); AzureRM 5.9.x; AWS provider 6.xUpdated: 14 October…

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