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
| Module | Labs | Topic | Cloud required? |
|---|---|---|---|
| 1 | 01–02 | Introduction to Terraform Functions | No |
| 2 | 03–05 | String Functions | No |
| 3 | 06–08 | Collection Functions | No |
| 4 | 09–10 | Numeric and Mathematical Functions | No |
| 5 | 11–12 | Type Conversion, Validation, and Error Handling | No |
| 6 | 13–15 | File, Encoding, and Data Functions | No |
| 7 | 16–17 | Filesystem, Path, and Time Functions | No |
| 8 | 18–22 | Advanced Functions and Real-World Use Cases | Only 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
terraformprovider. - 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 onlylocalsandoutput.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.hclin 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.tfvarsfile, referenced usingvar.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
- Run
az login, select your intended subscription, and setARM_SUBSCRIPTION_ID. - Ensure
~/.ssh/id_ed25519.pubexists; otherwise create a fresh key usingssh-keygen -t ed25519 -f ~/.ssh/id_ed25519(do not overwrite an existing key). - Use the dedicated
lab-21directory. Do not share state with your earlier 13-resource lab. - Run
terraform init,fmt,validateandplan -out=lab.tfplan. - Review the five creates, subscription and region. Only then apply
lab.tfplan. - Verify with Azure Run Command (no public IP/SSH connection needed), then
terraform destroywhen 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
- Configure AWS credentials through your approved IAM/SSO mechanism and confirm the identity using
aws sts get-caller-identity. - Run
terraform init,fmt,validate, andplan -out=lab.tfplanin the dedicated directory. - 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Function | Example expression | Why / 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
| Value | Meaning | Example |
|---|---|---|
path.module | Directory of the module whose expression is being evaluated | file("${path.module}/files/config.json") |
path.root | Root module directory | ${path.root}/templates |
path.cwd | Working directory in which Terraform was started (before -chdir) | Diagnostic use only |
When to use functions in production
| Task | Preferred approach | Caution |
|---|---|---|
| Make a readable VM name | format + lower + replace | Cloud provider name length/character restrictions still apply |
| Avoid blank collection values | compact, distinct, sort | Set iteration order cannot be relied on |
| Choose a default VM size | lookup with an explicit default | Validate every permitted SKU/region |
| Calculate subnets | cidrsubnet, cidrsubnets | Don’t change assigned CIDR positions in production |
| Render cloud-init | templatefile and (for AzureRM Linux VM) base64encode | Keep secrets out of templates/state |
| Read JSON or YAML config | jsondecode(file(...)), yamldecode(file(...)) | Only pre-existing UTF-8 files |
| Mask a CLI output | sensitive | Still visible in state to authorized readers |
| Default missing optional data | try, can, coalesce | Don’t hide bad schema errors with blanket fallbacks |
| Create a stable identifier | uuidv5 | Do not use uuid for resource name stability |
| Mark the current run time | A purpose-built time provider or external event | timestamp creates perpetual drift when attached to resource attributes |
Troubleshooting and common mistakes
| Symptom | Cause | Fix |
|---|---|---|
Invalid function argument for tobool("yes") | Only "true", "false", bool and null are convertible | Normalize at input boundary or reject the input |
regex fails for an unmatched pattern | regex does not return null | Use regexall for zero-or-more matches or can(regex(...)) |
index fails | Item does not exist | Check with contains or use lookup for maps |
one fails | Collection has more than one member | Make selection rules explicit |
element unexpectedly returns first item | Intentional modulo wrap-around | Use list[index] for strict indexing |
toset seems to reorder output | Sets have no guaranteed order | sort(tolist(toset(values))) |
merge overwrites a tag | Rightmost map wins | Put overrides intentionally last |
file() cannot find a file | Relative to wrong location or not created yet | ${path.module}/files/file.txt; only static files |
file() reports invalid UTF-8 | Binary data is not UTF-8 | Use filebase64 for bytes |
templatefile() reports missing key | vars object omits an interpolated name | Pass the key in the object |
| JSON is malformed after concatenation | Manual string building | Use jsonencode |
Azure custom_data is invalid | Unencoded user data on newer VM resource | base64encode(templatefile(...)) |
plantimestamp fails in console | Function is plan-only | Run terraform plan |
Diff on each plan after using timestamp | Time changes every run | Remove from stable resource attributes |
nonsensitive leaks a value | Sensitive mark deliberately removed | Use only when the derived value is demonstrably public |
| VM replacement after refactoring expressions | Renamed a resource or changed a ForceNew property | Revert the change; inspect terraform plan |
| Azure provider registration/quota failure | Subscription is not configured for chosen region/SKU | Check resource provider, quota and image availability |
| Azure private VM cannot accept SSH from laptop | No public IP or reachable private route | Use approved VPN/bastion or az vm run-command |
Provider init cannot download | Internet/proxy/registry limitations | Verify 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
- Can Terraform functions create resources? No; they transform or read values used elsewhere in configuration.
- What does
tryreturn? The first expression that does not raise a dynamic evaluation error. - Does
sensitiveencrypt state? No; it controls Terraform’s handling/display of the sensitivity mark. - Why prefer
jsonencodeover manually assembled JSON text? It correctly serializes nested data, quotes and escaping. - How do
timestampandplantimestampdiffer? Apply-time unpredictable result vs. plan-time fixed result; both vary across runs, andplantimestampcannot be evaluated in the console. - Can you reliably read a file created by another resource during the same apply using
file()? No. - Why does
sort(tolist(toset(...)))matter? It removes duplicates and guarantees sorted list output, rather than depending on set iteration order. - When to use
uuidv5? When a repeatable ID is needed from the same namespace and name. - How should students run optional cloud labs? Authenticate, use a separate state, run
init/validate, carefully review the plan, apply deliberately, verify, then destroy. - What is the safest way to introduce new function outputs into the uploaded Azure variables lab? Add
localsandoutputonly, 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
- Terraform built-in functions
- Terraform function calls and evaluation
- Terraform console
- Terraform type conversion and error functions
- Terraform provider-defined functions
- Terraform
plantimestamp - Terraform
cidrsubnets - Terraform
file - Terraform
templatefile - Terraform
nonsensitive - Azure Linux VM SSH keys
- AzureRM Linux VM resource
- AWS provider documentation
Last reviewed for this course: 14 October 2026. Actual cloud applies were not performed while generating the course.